设计模式之美(极客时间)🌟
# 20.设计模式之美(极客时间)🌟
# 开篇词与代码质量评判标准
# 开篇词-一对一的设计与编码集训,让你告别没有成长的烂代码!
# 1.程序员的看家本领你得练好
研究生毕业后我就加入了 Google, 至今我还清晰地记得, 我第一次提交代码的时候, 短短的 100 多行代码, 被同事 review 出了 n 多问题, 来来回回改了不下十几个版本才提交上去. 我当时有很大的逆反心理, 觉得有必要浪费这么多时间在如此细节的编码上吗? 只要代码能用, 能解决问题不就够了吗?
写代码可以说是程序员天天要干的事情, 要是代码都写不好, 最基本的看家本领都练不好, 成天堆砌烂代码, 写代码还有啥意思呢? 那还干啥程序员啊! 写出"能用"代码的人比比皆是, 但是, 并不是每个人都能写出"好用"的代码. 只会写能用的代码, 我们永远成长不成大牛, 成长不成最优秀的那批人.
后来我熟练掌握了各种编写高质量代码的技巧, 方法和理论, 我发现, 实际上, 写烂代码和好代码花费的时间是差不多的. 当你把写高质量代码培养成一种开发习惯之后, 在你在编写代码的时候, 自然就有一种代码质量意识, 自然而然就可以写出不错的代码. 即便在我离开 Google 加入其他公司之后, 项目的代码质量因为各种原因有所妥协, 但我起码知道什么样的代码是高质量代码, 丝毫不影响我具备写出高质量代码的能力.
我相信, 很多工程师都很重视代码质量, 毕竟谁也不想写被人吐槽的烂代码. 但是, 就我的了解来看, 毫不夸张地讲, 很多工程师, 甚至一些 BAT 的员工, 代码都写得惨不忍睹. 一方面, 在目前这种快糙猛的开发环境下, 很多工程师并没有太多时间去思考如何写高质量代码; 另一方面, 在烂代码的熏陶下, 在没有人指导的环境里, 很多工程师也搞不大清楚高质量代码到底长什么样.
这就导致很多工程师写了多年代码, 代码功力一点都没长进, 编写的代码仍然只是能用即可, 能运行就好. 平日的工作就是修修补补, 抄抄改改, 一直在做重复劳动, 能力也一直停留在"会干活"的层面, 就像高速路上的收银员, 只能算是一个"熟练工".
# 2.一个人闷头看书效果并不好
当然, 也有一些比较上进的工程师, 会去找设计模式, 编码规范, 重构等类型的书籍去看, 学习如何编写高质量的代码. 实际上, 我也买了很多这类的书籍来看, 从这些经典的书籍中, 我也学到了很多编程技巧和提高代码质量的方法.
不过, 这些书籍都有一个特点, 那就是比较偏重理论讲解, 喜欢拿猫, 狗之类生活中的例子来举例. 当然, 这样的例子也有优点, 那就是能在简短的时间和篇幅内, 很好地帮你理解原理. 但同时也存在一个严重的问题, 那就是过于脱离真实的软件开发. 而且例子本身没有难度, 你一看就觉得懂了, 但是看完之后, 可能还是不清楚如何将理论落地到实际的项目编码中.
比如, 我们都知道著名的 KISS 原则(Keep It Simple and Stupid). 这个原则理解起来很简单, 一看貌似就懂了, 那我问你, 怎样的代码才算是足够简单呢? 怎样才算不够简单需要优化呢? 估计很多人都回答不上来, 因为大部分书籍都没有讲清楚.
除此之外, 一个人自己闷头看书, 在很多时候效果并不好. 一方面, 每个人的理解能力是不一样的. 对于同一本书, 不同理解能力的人看完之后收获也是不一样的. 跟着有经验的老师学比闷头自己看书要更高效, 收获更多, 成长更快. 另一方面, 编码本身就是一门实践课, 光闷头看书本理论肯定是不够的, 更重要的是在实践中学习如何应用这些理论.
# 3.一对一手把手指导才最有效
从我的经验来看, 我觉得最有效, 最快速提高编码能力的方法就是, 找一个比你资深的工程师, 一对一, 手把手地指导你写代码. 你提交代码, 他来指出你的问题, 你再优化, 这样一来一往, 要不了多久, 你就会发现, 自己的代码能力突飞猛进.
但理想很丰满, 现实很骨感. 且不说能不能找到这样有资格指导你的人, 即便能找到, 他愿不愿意, 有没有时间来手把手指导你, 还是另外一回事. 而我比较幸运, 在毕业之后就加入了 Google, 得到了顶尖工程师的指导, 一对一地给我 review 代码, 手把手地指导我如何优化代码. 正因如此, 在 Google 的那段时间也成为了我编码能力提高最快的一段时间.
所以, 在设计专栏的初期, 我就在想, 如果我能模拟这样一个一对一, 手把手, 就真实项目代码讲解的场景, 是不是就能让专栏有别于千篇一律的书籍, 从而能真正提高你的代码能力呢?
# 4.我是如何设计这个专栏的?
100 多篇文章,50 万字,2万多行代码
整个专栏的文章总共有 100 多篇, 每篇平均下来在 5000 字左右, 所以你总共需要学习 50 万字. 为什么篇幅会这么多? 这是因为, **我想一次性把跟编写高质量代码相关的所有知识, 都系统, 全面地讲清楚, 一次性给你讲透彻. 你看完我这一个专栏, 就能搞清楚所有跟写高质量代码相关的知识点. **
除此之外, 为了避免脱离代码, 空洞地讲理论, 专栏每篇文章平均大约有 200 多行代码, 整个专栏累计有 2 万多行代码. 而且, 这些代码都来自我这十几年积累的真实项目, 而非编造出来的阿猫阿狗, 停车场, 餐厅之类的没有太多实际意义的代码.
我个人写 Java 代码比较多, 所以, 专栏中的代码我是用 Java 语言实现的. 不过, 在设计专栏的时候, 我已经考虑到其他不熟悉 Java 语言的小伙伴了. 我力争做到, 实际上我觉得也已经做到, 一方面, 专栏内容的讲解并不与具体的编程语言挂勾; 另一方面, 我只用最基本, 最简单的 Java 语法, 只要你有一些编程基础, 不管你熟悉的是哪种编程语言, 都可以看懂我写的代码, 理解代码背后的设计思想.
所以, 如果你熟悉的不是 Java, 而是 C++, C#, PHP, Python, Go, Ruby, JavaScript 等其他编程语言, 不要担心, 这完全不会影响你学习这个专栏.
200 多个真实的项目实战代码案例剖析
实际上, 大部分设计原则, 设计思想, 设计模式理解起来都不难, 难的是如何将这些理论灵活恰当地应用到实际的开发中, 而不是教条主义和盲目滥用. 而要想正确, 得当地应用理论知识, 光看书是不够的, 要在实战中去亲身感受, 体会这些理论该如何应用. 但是, 我们平时的开发更多的是基于已有的框架, 照着别人的流程, 扩展新的功能模块. 所以, 在工作中就可能没有那么多足够复杂, 足够有难度的开发场景, 让我们有机会去实践这些理论.
基于这种考虑, 我结合自己过去工作中积累的项目经验, 为整个专栏精心设计了 200 多个真实的项目实战代码案例. 几乎每节课, 每个知识点都会结合一个完整的开发案例来讲解.
# 01-为什么说每个程序员都要尽早地学习并掌握设计模式相关知识?
我相信, 很多程序员都已经意识到基础知识的重要性, 觉得要夯实基础, 才能走得更远, 但同时对于如何将基础知识转化成开发"生产力"仍然有些疑惑. 所以, 你可能看了很多基础的书籍, 比如操作系统, 组成原理, 编译原理等, 但还是觉得很迷茫, 觉得在开发中用不上, 起码在平时的 CRUD 业务开发中用不上. 实际上, 这些基础的知识确实很难直接转化成开发"生产力". 但是它能潜移默化地, 间接地提高你对技术的理解.
不过, 我觉得, 设计模式和操作系统, 组成原理, 编译原理等这些基础学科是不一样的. 它虽然也算是一门基础知识, 但是它和数据结构, 算法更像是一道儿的, 相比那些更加基础的学科, 设计模式能更直接地提高你的开发能力. 设计模式讲的是如何写出可扩展, 可读, 可维护的高质量代码, 所以, 它们跟平时的编码会有直接的关系, 也会直接影响到你的开发能力.
不过, 你可能还是会觉得设计模式是把屠龙刀, 看起来很厉害, 但平时的开发根本用不上. 基于这种观点, 接下来, 就具体地聊一聊为什么要学习设计模式?
告别写被人吐槽的烂代码
我们经常说, "Talk is cheap, show me the code." 实际上, 代码能力是一个程序员最基础的能力, 是基本功, 是展示一个程序员基础素养的最直接的衡量标准. 你写的代码, 实际上就是你名片.
尽管我已经工作近十年, 但我一直没有脱离编码一线, 现在每天也都在坚持写代码, review 指导同事写代码, 重构遗留系统的烂代码. 这些年的工作经历中, 我见过太多的烂代码, 比如命名不规范, 类设计不合理, 分层不清晰, 没有模块化概念, 代码结构混乱, 高度耦合等等. 这样的代码维护起来非常费劲, 添加或者修改一个功能, 常常会牵一发而动全身, 让你无从下手, 恨不得将全部的代码删掉重写!
当然, 在这些年的工作经历中, 我也看到过很多让我眼前一亮的代码. 每当我看到这样的好代码, 都会立刻对作者产生无比的好感和认可. 且不管这个人处在公司的何种级别, 从代码就能看出, 他是一个基础扎实的高潜员工, 值得培养, 前途无量! 因此, 代码写得好, 能让你在团队中脱颖而出.
所以, 我的专栏, 不仅仅只是讲解设计模式, 更加重要的是, 我会通过实战例子, 手把手教你如何避免刚刚提到的代码问题, 告别被人诟病的烂代码, 写出令人称道的好代码, 成为团队中的代码标杆! 而且, 写出一份漂亮的代码, 你自己也会很有成就感.
提高复杂代码的设计和开发能力
大部分工程师比较熟悉的都是编程语言, 工具, 框架这些东西, 因为每天的工作就是在框架里根据业务需求, 填充代码. 实际上, 我刚工作的时候, 也是做这类事情. 相对来说, 这样的工作并不需要你具备很强的代码设计能力, 只要单纯地能理解业务, 翻译成代码就可以了.
但是, 有一天, 我的 leader 让我开发一个跟业务无关的比较通用的功能模块, 面对这样稍微复杂的代码设计和开发, 我就发现我有点力不从心, 不知从何下手了. 因为我知道只是完成功能, 代码能用, 可能并不复杂, 但是要想写出易扩展, 易用, 易维护的代码, 并不容易.
如何分层, 分模块? 应该怎么划分类? 每个类应该具有哪些属性, 方法? 怎么设计类之间的交互? 该用继承还是组合? 该使用接口还是抽象类? 怎样做到解耦, 高内聚低耦合? 该用单例模式还是静态方法? 用工厂模式创建对象还是直接 new 出来? 如何避免引入设计模式提高扩展性的同时带来的降低可读性问题? ……各种问题, 一下子挤到了我面前.
而我当时并没有对设计模式相关的知识(包括设计模式, 设计原则, 面向对象设计思想等)有太多的了解和积累, 所以一时间搞得我手足无措. 好在因此我意识到了这方面知识的重要性, 所以在之后很多年的开发中, 我都一直刻意锻炼, 积累这方面的能力. 面对复杂代码, 功能, 系统的设计和开发, 我也越来越得心应手, 游刃有余. 写出高质量代码已经成为了我的习惯, 不经意间写出来的代码, 都能作为同事学习, 临摹的范例, 这也成为了我职场中最引以为豪的亮点之一.
让读源码,学框架事半功倍
对于一个有追求的程序员来说, 对技术的积累, 既要有广度, 也要有深度. 很多技术人早早就意识到了这一点, 所以在学习框架, 中间件的时候, 都会抽空去研究研究原理, 读一读源码, 希望能在深度上有所积累, 而不只是略知皮毛, 会用而已.
从我的经验和同事的反馈来看, 有些人看源码的时候, 经常会遇到看不懂, 看不下去的问题. 不知道你有没有遇到过这种情况? 实际上, 这个问题的原因很简单, 那就是你积累的基本功还不够, 你的能力还不足以看懂这些代码. 为什么我会这么说呢?
优秀的开源项目, 框架, 中间件, 代码量, 类的个数都会比较多, 类结构, 类之间的关系极其复杂, 常常调用来调用去. 所以, 为了保证代码的扩展性, 灵活性, 可维护性等, 代码中会使用到很多设计模式, 设计原则或者设计思想. 如果你不懂这些设计模式, 原则, 思想, 在看代码的时候, 你可能就会琢磨不透作者的设计思路, 对于一些很明显的设计思路, 你可能要花费很多时间才能参悟. 相反, 如果你对设计模式, 原则, 思想非常了解, 一眼就能参透作者的设计思路, 设计初衷, 很快就可以把脑容量释放出来, 重点思考其他问题, 代码读起来就会变得轻松了.
实际上, 除了看不懂, 看不下去的问题, 还有一个隐藏的问题, 你可能自己都发现不了, 那就是你自己觉得看懂了, 实际上, 里面的精髓你并没有 get 到多少! 因为优秀的开源项目, 框架, 中间件, 就像一个集各种高精尖技术在一起的战斗机. 如果你想剖析它的原理, 学习它的技术, 而你没有积累深厚的基本功, 就算把这台战斗机摆在你面前, 你也不能完全参透它的精髓, 只是了解个皮毛, 看个热闹而已.
因此, 学好设计模式相关的知识, 不仅能让你更轻松地读懂开源项目, 还能更深入地参透里面的技术精髓, 做到事半功倍.
为你的职场发展做铺垫
普通的, 低级别的开发工程师, 只需要把框架, 开发工具, 编程语言用熟练, 再做几个项目练练手, 基本上就能应付平时的开发工作了. 但是, 如果你不想一辈子做一个低级的码农, 想成长为技术专家, 大牛, 技术 leader, 希望在职场有更高的成就, 更好的发展, 那就要重视基本功的训练, 基础知识的积累.
你去看大牛写的代码, 或者优秀的开源项目, 代码写得都非常的优美, 质量都很高. 如果你只是框架用得很溜, 架构聊得头头是道, 但写出来的代码很烂, 让人一眼就能看出很多不合理的, 可以改进的地方, 那你永远都成不了别人心目中的"技术大牛".
再者, 在技术这条职场道路上, 当成长到一定阶段之后, 你势必要承担一些指导培养初级员工, 新人, 以及 code review 的工作. 这个时候, 如果你自己都对"什么是好的代码? 如何写出好的代码? "不了解, 那又该如何指导别人, 如何让人家信服呢?
还有, 如果你是一个技术 leader, 负责一个项目整体的开发工作, 你就需要为开发进度, 开发效率和项目质量负责. 你也不希望团队堆砌垃圾代码, 让整个项目无法维护, 添加, 修改一个功能都要费老大劲, 最终拉低整个团队的开发效率吧?
除此之外, 代码质量低还会导致线上 bug 频发, 排查困难. 整个团队都陷在成天修改无意义的低级 bug, 在烂代码中添补丁的事情中. 而一个设计良好, 易维护的系统, 可以解放我们的时间, 让我们做些更加有意义, 更能提高自己和团队能力的事情.
最后, 当你成为 leader, 或者团队中的资深工程师, 技术专家之后, 你势必要负责一部分团队的招聘工作. 这个时候, 如果你要考察候选人的设计能力, 代码能力, 那设计模式相关的问题便是一个很好的考察点.
不过, 我也了解到, 很多面试官实际上对设计模式也并不是很了解, 只能拿一些简单的单例模式, 工厂模式来考察候选人, 而且所出的题目往往都脱离实践, 比如, 如何设计一个餐厅系统, 停车场系统, 售票系统等. 这些题目都是网上万年不变的老题目, 几乎考察不出候选人的能力. 在我的专栏中, 有 200 多个真实项目开发中的设计模式相关问题, 你跟着看下来, 足以让你成为设计模式方面的大牛, 再来面试候选人的时候, 就不用因为题目老套, 脱离实践而尴尬了!
# 02-从哪些维度评判代码质量的好坏?如何具备写出高质量代码的能力?
在我的工作经历中, 每当同事评论起项目代码质量的时候, 听到的最多的评语就是: "代码写得很烂"或者"代码写得很好". 当具体问到底如何烂, 如何好的时候, 尽管大部分同事都能简单地罗列上几个点, 但往往都不够全面, 非常零碎, 也切不中要害.
当然, 也有一些工程师对如何评价代码质量有所认识, 比如, 好代码是易扩展, 易读, 简单, 易维护的等等, 但他们对于这些评价的理解往往只停留在表面概念上, 对于诸多更深入的问题, 比如, "怎么才算可读性好? 什么样的代码才算易扩展, 易维护? 可读, 可扩展与可维护之间有什么关系? 可维护中‘维护’两字该如何理解?" 等等, 并没有太清晰的认识.
对于程序员来说, 辨别代码写得"好"还是"烂", 是一个非常重要的能力. 这也是写出好代码的前提. 毕竟, 如果连什么是好代码, 什么是烂代码, 都分辨不清, 又谈何写出好代码呢?
所以本节就聊一聊关于代码质量评判的相关问题, 希望你在学完后对代码质量的评判有个更加清晰, 更加透彻的认识和理解.
# 1.如何评价代码质量的高低?
实际上, 咱们平时嘴中常说的"好"和"烂", 是对代码质量的一种描述. "好"笼统地表示代码质量高, "烂"笼统地表示代码质量低. 对于代码质量的描述, 除了"好""烂"这样比较简单粗暴的描述方式之外, 我们也经常会听到很多其他的描述方式. 这些描述方法语义更丰富, 更专业, 更细化. 这里搜集整理了一下, 罗列在了下面. 这些几乎涵盖我们所能听到的描述代码质量的所有常用词汇.
灵活性(flexibility), 可扩展性(extensibility), 可维护性(maintainability), 可读性(readability), 可理解性(understandability), 易修改性(changeability), 可复用(reusability), 可测试性(testability), 模块化(modularity), 高内聚低耦合(high cohesion loose coupling), 高效(high effciency), 高性能(high performance), 安全性(security), 兼容性(compatibility), 易用性(usability), 整洁(clean), 清晰(clarity), 简单(simple), 直接(straightforward), 少即是多(less code is more), 文档详尽(well-documented), 分层清晰(well-layered), 正确性(correctness, bug free), 健壮性(robustness), 鲁棒性(robustness), 可用性(reliability), 可伸缩性(scalability), 稳定性(stability), 优雅(elegant), 好(good), 坏(bad)...
看到如此多的描述词, 你可能要问了, 到底该用哪些词来描述一段代码的质量呢?
实际上, 很难通过其中的某个或者某几个词汇来全面地评价代码质量. 因为这些词汇都是从不同维度来说的. 这就好比, 对于一个人的评价, 我们需要综合各个方面来给出, 比如性格, 相貌, 能力, 财富等等. 代码质量高低也是一个综合各种因素得到的结论. 我们并不能通过单一的维度去评价一段代码写的好坏. 比如, 即使一段代码的可扩展性很好, 但可读性很差, 那也不能说这段代码质量高.
除此之外, 不同的评价维度也并不是完全独立的, 有些是具有包含关系, 重叠关系或者可以互相影响的. 比如, 代码的可读性好, 可扩展性好, 就意味着代码的可维护性好. 而且, 各种评价维度也不是非黑即白的. 比如, 我们不能简单地将代码分为可读与不可读. 如果用数字来量化代码的可读性的话, 它应该是一个连续的区间值, 而非 0, 1 这样的离散值.
不过, 真的可以客观地量化一段代码质量的高低吗? 答案是否定的. 对一段代码的质量评价, 常常有很强的主观性. 比如, 怎么样的代码才算可读性好, 每个人的评判标准都不大一样. 正是因为代码质量评价的主观性, 使得这种主观评价的准确度, 跟工程师自身经验有极大的关系. 越是有经验的工程师, 给出的评价也就越准确. 相反, 资历比较浅的工程师就常常会觉得, 没有一个可执行的客观的评价标准作为参考, 很难准确地判断一段代码写得好与坏. 有的时候, 自己觉得代码写得已经够好了, 但实际上并不是. 所以这也导致如果没有人指导的话, 自己一个人闷头写代码, 即便写再多的代码, 代码能力也可能一直没有太大提高.
# 2.最常用的评价标准有哪几个?
仔细看前面罗列的所有代码质量评价标准, 你会发现, 有些词语过于笼统, 抽象, 比较偏向对于整体的描述, 比如优雅, 好, 坏, 整洁, 清晰等; 有些过于细节, 偏重方法论, 比如模块化, 高内聚低耦合, 文档详尽, 分层清晰等; 有些可能并不仅仅局限于编码, 跟架构设计等也有关系, 比如可伸缩性, 可用性, 稳定性等.
这里挑选了其中几个最常用的, 最重要的评价标准, 来详细讲解, 其中就包括: 可维护性, 可读性, 可扩展性, 灵活性, 简洁性(简单, 复杂), 可复用性, 可测试性.
# (1)可维护性(maintainability)
什么是代码的"可维护性"? 所谓的"维护代码"到底包含哪些具体工作?
落实到编码开发, 所谓的"维护"无外乎就是修改 bug, 修改老的代码, 添加新的代码之类的工作. 所谓"代码易维护"就是指, 在不破坏原有代码设计, 不引入新的 bug 的情况下, 能够快速地修改或者添加代码. 所谓"代码不易维护"就是指, 修改或者添加代码需要冒着极大的引入新 bug 的风险, 并且需要花费很长的时间才能完成.
对于一个项目来说, 维护代码的时间远远大于编写代码的时间. 工程师大部分的时间可能都是花在修修 bug, 改改老的功能逻辑, 添加一些新的功能逻辑之类的工作上. 所以代码的可维护性就显得格外重要. 维护, 易维护, 不易维护这三个概念不难理解. 对于实际的软件开发来说, 更重要的是搞清楚, 如何来判断代码可维护性的好坏.
实际上, 可维护性也是一个很难量化, 偏向对代码整体的评价标准, 它有点类似之前提到的"好""坏""优雅"之类的笼统评价. 代码的可维护性是由很多因素协同作用的结果. 代码的可读性好, 简洁, 可扩展性好, 就会使得代码易维护; 相反, 就会使得代码不易维护. 更细化地讲, 如果代码分层清晰, 模块化好, 高内聚低耦合, 遵从基于接口而非实现编程的设计原则等等, 那就可能意味着代码易维护. 除此之外, 代码的易维护性还跟项目代码量的多少, 业务的复杂程度, 利用到的技术的复杂程度, 文档是否全面, 团队成员的开发水平等诸多因素有关.
所以, 从正面去分析一个代码是否易维护稍微有点难度. 不过可以从侧面上给出一个比较主观但又比较准确的感受. 如果 bug 容易修复, 修改, 添加功能能够轻松完成, 那就可以主观地认为代码易维护.
你可能会说, 这样的评价方式也太主观了吧? 没错, 是否易维护本来就是针对维护的人来说的. 不同水平的人对于同一份代码的维护能力并不是相同的. 对于同样一个系统, 熟悉它的资深工程师会觉得代码的可维护性还不错, 而一些新人因为不熟悉代码, 修改 bug, 修改添加代码要花费很长的时间, 就有可能会觉得代码的可维护性不那么好. 这实际上也印证了之前的观点: 代码质量的评价有很强的主观性.
# (2)可读性(readability)
软件设计大师 Martin Fowler 曾经说过: "任何傻瓜都会编写计算机能理解的代码. 好的程序员能够编写人能够理解的代码." Google 内部甚至专门有个认证就叫作 Readability. 只有拿到这个认证的工程师, 才有资格在 code review 的时候, 批准别人提交代码. 可见代码的可读性有多重要, 毕竟, 代码被阅读的次数远远超过被编写和执行的次数.
个人认为, 代码的可读性应该是评价代码质量最重要的指标之一. 在编写代码的时候, 时刻要考虑到代码是否易读, 易理解. 除此之外, 代码的可读性在非常大程度上会影响代码的可维护性. 毕竟, 不管是修改 bug, 还是修改添加功能代码, 首先要做的事情就是读懂代码. 代码读不大懂, 就很有可能因为考虑不周全, 而引入新的 bug.
既然可读性如此重要, 那又该如何评价一段代码的可读性呢?
需要看代码是否符合编码规范, 命名是否达意, 注释是否详尽, 函数是否长短合适, 模块划分是否清晰, 是否符合高内聚低耦合等等. 你应该也能感觉到, 从正面上, 很难给出一个覆盖所有评价指标的列表. 这也是无法量化可读性的原因.
实际上, code review 是一个很好的测验代码可读性的手段. 如果你的同事可以轻松地读懂你写的代码, 那说明你的代码可读性很好; 如果同事在读你的代码时, 有很多疑问, 那就说明你的代码可读性有待提高了.
# (3)可扩展性(extensibility)
可扩展性也是一个评价代码质量非常重要的标准. 它表示代码应对未来需求变化的能力. 跟可读性一样, 代码是否易扩展也很大程度上决定代码是否易维护. 那到底什么是代码的可扩展性呢?
代码的可扩展性表示, 在不修改或少量修改原有代码的情况下, 通过扩展的方式添加新的功能代码. 说直白点就是, 代码预留了一些功能扩展点, 你可以把新功能代码, 直接插到扩展点上, 而不需要因为要添加一个功能而大动干戈, 改动大量的原始代码.
关于代码的扩展性, 在后面讲到"对修改关闭, 对扩展开放"这条设计原则的时候, 会来详细讲解, 现在只需要知道, 代码的可扩展性是评价代码质量非常重要的标准就可以了.
# (4)灵活性(flexibility)
灵活性也是描述代码质量的一个常用词汇. 比如经常会听到这样的描述: "代码写得很灵活". 那这里的"灵活"该如何理解呢?
尽管有很多人用这个词汇来描述代码的质量. 但实际上, 灵活性是一个挺抽象的评价标准, 要给灵活性下个定义也是挺难的. 不过, 我们可以想一下, 什么情况下我们才会说代码写得好灵活呢? 我这里罗列了几个场景, 希望能引发你自己对什么是灵活性的思考.
- 当我们添加一个新的功能代码的时候, 原有的代码已经预留好了扩展点, 我们不需要修改原有的代码, 只要在扩展点上添加新的代码即可. 这个时候, 除了可以说代码易扩展, 还可以说代码写得好灵活.
- 当要实现一个功能的时候, 发现原有代码中, 已经抽象出了很多底层可以复用的模块, 类等代码, 我们可以拿来直接使用. 这个时候, 除了可以说代码易复用之外, 还可以说代码写得好灵活.
- 当使用某组接口的时候, 如果这组接口可以应对各种使用场景, 满足各种不同的需求, 除了可以说接口易用之外, 还可以说这个接口设计得好灵活或者代码写得好灵活.
从刚刚举的场景来看, 如果一段代码易扩展, 易复用或者易用, 都可以称这段代码写得比较灵活. 所以, 灵活这个词的含义非常宽泛, 很多场景下都可以使用.
# (5)简洁性(simplicity)
有一条非常著名的设计原则, 你一定听过, 那就是 KISS 原则: "Keep It Simple, Stupid". 这个原则说的意思就是, 尽量保持代码简单. 代码简单, 逻辑清晰, 也就意味着易读, 易维护. 在编写代码的时候, 往往也会把简单, 清晰放到首位.
不过, 很多编程经验不足的程序员会觉得, 简单的代码没有技术含量, 喜欢在项目中引入一些复杂的设计模式, 觉得这样才能体现自己的技术水平. 实际上, **思从深而行从简, 真正的高手能云淡风轻地用最简单的方法解决最复杂的问题. 这也是一个编程老手跟编程新手的本质区别之一. **
# (6)可复用性(reusability)
代码的可复用性可以简单地理解为, 尽量减少重复代码的编写, 复用已有的代码. 在后面的很多章节中, 我们都会经常提到"可复用性"这一代码评价标准.
比如, 当讲到面向对象特性的时候, 我们会讲到继承, 多态存在的目的之一, 就是为了提高代码的可复用性; 当讲到设计原则的时候, 我们会讲到单一职责原则也跟代码的可复用性相关; 当讲到重构技巧的时候, 我们会讲到解耦, 高内聚, 模块化等都能提高代码的可复用性. 可见, 可复用性也是一个非常重要的代码评价标准, 是很多设计原则, 思想, 模式等所要达到的最终效果.
实际上, 代码可复用性跟 DRY(Don’t Repeat Yourself)这条设计原则的关系挺紧密的, 所以, 在后面的章节中, 当我们讲到 DRY 设计原则的时候, 我还会讲更多代码复用相关的知识, 比如, "有哪些编程方法可以提高代码的复用性"等.
# (7)可测试性(testability)
相对于前面六个评价标准, 代码的可测试性是一个相对较少被提及, 但又非常重要的代码质量评价标准. 代码可测试性的好坏, 能从侧面上非常准确地反应代码质量的好坏. 代码的可测试性差, 比较难写单元测试, 那基本上就能说明代码设计得有问题. 关于代码的可测试性, 我们在重构那一部分, 会花两节课的时间来详细讲解. 现在, 你暂时只需要知道, 代码的可测试性非常重要就可以了.
# 3.如何才能写出高质量的代码?
我相信每个工程师都想写出高质量的代码, 不想一直写没有成长, 被人吐槽的烂代码. 那如何才能写出高质量的代码呢? 针对什么是高质量的代码, 刚刚讲到了七个最常用, 最重要的评价指标. 所以, 问如何写出高质量的代码, 也就等同于在问, 如何写出易维护, 易读, 易扩展, 灵活, 简洁, 可复用, 可测试的代码.
要写出满足这些评价标准的高质量代码, 我们需要掌握一些更加细化, 更加能落地的编程方法论, 包括面向对象设计思想, 设计原则, 设计模式, 编码规范, 重构技巧等. 而所有这些编程方法论的最终目的都是为了编写出高质量的代码.
比如, 面向对象中的继承, 多态能让我们写出可复用的代码; 编码规范能让我们写出可读性好的代码; 设计原则中的单一职责, DRY, 基于接口而非实现, 里式替换原则等, 可以让我们写出可复用, 灵活, 可读性好, 易扩展, 易维护的代码; 设计模式可以让我们写出易扩展的代码; 持续重构可以时刻保持代码的可维护性等等. 具体这些编程方法论是如何提高代码的可维护性, 可读性, 可扩展性等等的呢? 我们在后面的课程中慢慢来学习.
# 面向对象
# 03-面向对象, 设计原则, 设计模式, 编程规范, 重构, 这五者有何关系?
在上一节讲到, 要具备编写高质量代码的能力, 需要学习一些编程方法论, 其中就包含面向对象, 设计原则, 设计模式, 编程规范, 重构技巧等. 而整个专栏的内容也是围绕着这几块展开讲解的. 所以这里就先来简单介绍一下这几个概念, 并且说一说它们之间的联系. 本节的内容相当于专栏的一个教学大纲, 或者说学习框架. 它能让你对整个专栏所涉及的知识点, 有一个全局性的了解, 能帮你将后面零散的知识更系统地组织在大脑里.
# 1.面向对象
现在, 主流的编程范式或者是编程风格有三种, 它们分别是面向过程, 面向对象和函数式编程. 面向对象这种编程风格又是这其中最主流的. 现在比较流行的编程语言大部分都是面向对象编程语言. 大部分项目也都是基于面向对象编程风格开发的. 面向对象编程因为其具有丰富的特性(封装, 抽象, 继承, 多态), 可以实现很多复杂的设计思路, 是很多设计原则, 设计模式编码实现的基础.
对于面向对象, 你需要掌握下面这 7 个大的知识点.
- 面向对象的四大特性: 封装, 抽象, 继承, 多态
- 面向对象编程与面向过程编程的区别和联系
- 面向对象分析, 面向对象设计, 面向对象编程
- 接口和抽象类的区别以及各自的应用场景
- 基于接口而非实现编程的设计思想
- 多用组合少用继承的设计思想
- 面向过程的贫血模型和面向对象的充血模型
# 2.设计原则
设计原则是指导我们代码设计的一些经验总结. 设计原则的知识有一个非常大的特点, 那就是这些原则听起来都比较抽象, 定义描述都比较模糊, 不同的人会有不同的解读. 所以, 如果单纯地去记忆定义, 对于编程, 设计能力的提高, 意义并不大. 对于每一种设计原则, 需要掌握它的设计初衷, 能解决哪些编程问题, 有哪些应用场景. 只有这样, 才能在项目中灵活恰当地应用这些原则.
对于这一部分内容, 需要透彻理解并且掌握, 如何应用下面这样几个常用的设计原则.
- SOLID 原则: SRP 单一职责原则
- SOLID 原则: OCP 开闭原则
- SOLID 原则: LSP 里式替换原则
- SOLID 原则: ISP 接口隔离原则
- SOLID 原则: DIP 依赖倒置原则
- DRY 原则, KISS 原则, YAGNI 原则, LOD 法则
# 3.设计模式
设计模式是针对软件开发中经常遇到的一些设计问题, 总结出来的一套解决方案或者设计思路. 大部分设计模式要解决的都是代码的可扩展性问题. 设计模式相对于设计原则来说, 没有那么抽象, 而且大部分都不难理解, 代码实现也并不复杂. 这一块的学习难点是了解它们都能解决哪些问题, 掌握典型的应用场景, 并且懂得不过度应用.
经典的设计模式有 23 种. 随着编程语言的演进, 一些设计模式(比如 Singleton)也随之过时, 甚至成了反模式, 一些则被内置在编程语言中(比如 Iterator), 另外还有一些新的模式诞生(比如 Monostate).
它们又可以分为三大类: 创建型, 结构型, 行为型. 对于这 23 种设计模式的学习, 要有侧重点, 因为有些模式是比较常用的, 有些模式是很少被用到的. 对于常用的设计模式, 要花多点时间理解掌握. 对于不常用的设计模式, 只需要稍微了解即可.
这里按照类型和是否常用, 对这些设计模式进行了简单的分类, 具体如下所示.
# (1)创建型
常用的有: 单例模式, 工厂模式(工厂方法和抽象工厂), 建造者模式.
不常用的有: 原型模式.
# (2)结构型
常用的有: 代理模式, 桥接模式, 装饰者模式, 适配器模式.
不常用的有: 门面模式, 组合模式, 享元模式.
# (3)行为型
常用的有: 观察者模式, 模板模式, 策略模式, 职责链模式, 迭代器模式, 状态模式.
不常用的有: 访问者模式, 备忘录模式, 命令模式, 解释器模式, 中介模式.
# 4.编程规范与代码重构
编程规范主要解决的是代码的可读性问题. 编码规范相对于设计原则, 设计模式, 更加具体, 更加偏重代码细节. 即便你可能对设计原则不熟悉, 对设计模式不了解, 但最起码要掌握基本的编码规范, 比如如何给变量, 类, 函数命名, 如何写代码注释, 函数不宜过长, 参数不能过多等等.
对于编码规范, 考虑到很多书籍已经讲得很好了(比如《重构》《代码大全》《代码整洁之道》等). 而且每条编码规范都非常简单, 非常明确, 比较偏向于记忆, 只要照着来做可以. 它不像设计原则, 需要融入很多个人的理解和思考. 所以这个专栏并没有花太多的篇幅来讲解所有的编码规范, 而是总结了我认为的最能改善代码质量的 20 条规范.
除此之外, 专栏并没有将编码规范单独作为一个模块来讲解, 而是跟重构放到了一起. 之所以这样做, 那是因为我把重构分为大重构和小重构两种类型, 而小重构利用的知识基本上就是编码规范.
除了编码规范, 我们还会介绍一些代码的坏味道, 让你知道什么样的代码是不符合规范的, 应该如何优化. 参照编码规范, 你可以写出可读性好的代码; 参照代码的坏味道, 你可以找出代码存在的可读性问题.
在软件开发中, 只要软件在不停地迭代, 就没有一劳永逸的设计. 随着需求的变化, 代码的不停堆砌, 原有的设计必定会存在这样那样的问题. 针对这些问题, 就需要进行代码重构. 重构是软件开发中非常重要的一个环节. 持续重构是保持代码质量不下降的有效手段, 能有效避免代码腐化到无可救药的地步.
而重构的工具就是前面罗列的那些面向对象设计思想, 设计原则, 设计模式, 编码规范. 实际上, 设计思想, 设计原则, 设计模式一个最重要的应用场景就是在重构的时候. 前面讲过, 虽然使用设计模式可以提高代码的可扩展性, 但过度不恰当地使用, 也会增加代码的复杂度, 影响代码的可读性. 在开发初期, 除非特别必须, 一定不要过度设计而应用复杂的设计模式. 而是当代码出现问题的时候, 再针对问题, 应用原则和模式进行重构. 这样就能有效避免前期的过度设计.
对于重构这部分内容, 需要掌握以下几个知识点:
- 重构的目的(why), 对象(what), 时机(when), 方法(how);
- 保证重构不出错的技术手段: 单元测试和代码的可测试性;
- 两种不同规模的重构: 大重构(大规模高层次)和小重构(小规模低层次).
希望你学完这部分内容之后, 不仅仅是掌握一些重构技巧, 套路, 更重要的是建立持续重构意识, 把重构当作开发的一部分, 融入到日常的开发中.
# 5.五者之间的联系
关于面向对象, 设计原则, 设计模式, 编程规范和代码重构, 这五者的关系前面稍微提到了一些, 这里再总结梳理一下.
- 面向对象编程因为其具有丰富的特性(封装, 抽象, 继承, 多态), 可以实现很多复杂的设计思路, 是很多设计原则, 设计模式等编码实现的基础.
- 设计原则是指导代码设计的一些经验总结, 对于某些场景下, 是否应该应用某种设计模式, 具有指导意义. 比如, "开闭原则"是很多设计模式(策略, 模板等)的指导原则.
- 设计模式是针对软件开发中经常遇到的一些设计问题, 总结出来的一套解决方案或者设计思路. 应用设计模式的主要目的是提高代码的可扩展性. 从抽象程度上来讲, 设计原则比设计模式更抽象. 设计模式更加具体, 更加可执行.
- 编程规范主要解决的是代码的可读性问题. 编码规范相对于设计原则, 设计模式, 更加具体, 更加偏重代码细节, 更加能落地. 持续的小重构依赖的理论基础主要就是编程规范.
- 重构作为保持代码质量不下降的有效手段, 利用的就是面向对象, 设计原则, 设计模式, 编码规范这些理论.
实际上, 面向对象, 设计原则, 设计模式, 编程规范, 代码重构, 这五者都是保持或者提高代码质量的方法论, 本质上都是服务于编写高质量代码这一件事的. 当我们看清这个本质之后, 很多事情怎么做就清楚了, 很多选择怎么选也清楚了. 比如, 在某个场景下, 该不该用这个设计模式, 那就看能不能提高代码的可扩展性; 要不要重构, 那就看重代码是否存在可读, 可维护问题等.
重点回顾
下面的图总结了专栏中所涉及的知识点.

# 04-理论一:当谈论面向对象的时候,我们到底在谈论什么?
考虑到各个水平层次的同学, 并且保证专栏内容的系统性, 全面性, 我会循序渐进地讲解跟设计模式相关的所有内容. 所以, 专栏正文的第一个模块, 我会讲一些设计原则, 设计思想, 比如面向对象设计思想, 经典设计原则以及重构相关的知识, 为之后学习设计模式做铺垫.
在第一个模块中, 又首先会讲到面向对象相关的理论知识. 提到面向对象, 我相信很多人都不陌生, 随口都可以说出面向对象的四大特性: 封装, 抽象, 继承, 多态. 实际上, 面向对象这个概念包含的内容还不止这些. 所以本节先大概跟你聊一下, 当谈论面向对象的时候, 经常会谈到的一些概念和知识点, 为学习后面的几节更加细化的内容做一个铺垫.
# 1.面向对象编程和面向对象编程语言
面向对象编程的英文缩写是 OOP, 全称是 Object Oriented Programming. 面向对象编程中有两个非常重要, 非常基础的概念, 那就是类(class)和对象(object) .
1980 年左右, C++ 的出现, 带动了面向对象编程的流行, 也使得面向对象编程被越来越多的人认可. 直到今天, 如果不按照严格的定义来说, 大部分编程语言都是面向对象编程语言, 比如 Java, C++, Go, Python, C#, Ruby, JavaScript, Objective-C, Scala, PHP, Perl 等等. 除此之外, 大部分程序员在开发项目的时候, 都是基于面向对象编程语言进行的面向对象编程.
以上是面向对象编程的大概发展历史. 在刚刚的描述中, 着重提到了两个概念, 面向对象编程和面向对象编程语言. 那究竟什么是面向对象编程? 什么语言才算是面向对象编程语言呢? 如果非得给出一个定义的话, 可以用下面两句话来概括.
- 面向对象编程是一种编程范式或编程风格. 它以类或对象作为组织代码的基本单元, 并将封装, 抽象, 继承, 多态四个特性, 作为代码设计和实现的基石.
- 面向对象编程语言是支持类或对象的语法机制, 并有现成的语法机制, 能方便地实现面向对象编程四大特性(封装, 抽象, 继承, 多态)的编程语言.
一般来讲, 面向对象编程都是通过使用面向对象编程语言来进行的, 但是不用面向对象编程语言, 照样可以进行面向对象编程. 反过来讲, 即便使用面向对象编程语言, 写出来的代码也不一定是面向对象编程风格的, 也有可能是面向过程编程风格的. .
除此之外, 从定义中还可以发现, 理解面向对象编程及面向对象编程语言两个概念, 其中最关键的一点就是理解面向对象编程的四大特性. 这四大特性分别是: 封装, 抽象, 继承, 多态. 不过, 关于面向对象编程的特性, 也有另外一种说法, 那就是只包含三大特性: 封装, 继承, 多态, 不包含抽象. 为什么会有这种分歧呢? 抽象为什么可以排除在面向对象编程特性之外呢? 关于这个问题, 在下一节课详细讲解这四大特性的时候, 还会再拿出来说一下. 不过, 话说回来, 实际上, 我们没必要纠结到底是四大特性还是三大特性, 关键还是理解每种特性讲的是什么内容, 存在的意义以及能解决什么问题.
而且, 在技术圈里, 封装, 抽象, 继承, 多态也并不是固定地被叫作"四大特性"(features), 也有人称它们为面向对象编程的四大概念(concepts), 四大基石(cornerstones), 四大基础(fundamentals)等等. 叫法挺混乱的. 不过, 叫什么并不重要. 只需要知道, 这是前人进行面向对象编程过程中总结出来的, 能让我们更容易地实现各种设计思路的几个编程套路, 这就够了. 在之后的课程中会统一把它们叫作"四大特性".
# 2.如何判定某编程语言是否是面向对象编程语言?
前面提到, "如果不按照严格的定义来说, 大部分编程语言都是面向对象编程语言". 为什么要加上"如果不按照严格的定义"这个前提呢? 那是因为, 如果按照刚刚给出的严格的面向对象编程语言的定义, 前面提到的有些编程语言, 并不是严格意义上的面向对象编程语言, 比如 JavaScript, 它不支持封装和继承特性, 按照严格的定义, 它不算是面向对象编程语言, 但在某种意义上, 它又可以算得上是一种面向对象编程语言. 为什么这么说呢? 到底该如何判断一个编程语言是否是面向对象编程语言呢?
还记得前面给出的面向对象编程及面向对象编程语言的定义吗? 我必须坦诚告诉你, 那个定义是我自己给出的. 实际上, 对于什么是面向对象编程, 什么是面向对象编程语言, 并没有一个官方的, 统一的定义.
实际上, 面向对象编程从字面上, 按照最简单, 最原始的方式来理解, 就是将对象或类作为代码组织的基本单元, 来进行编程的一种编程范式或者编程风格, 并不一定需要封装, 抽象, 继承, 多态这四大特性的支持. 但是, 在进行面向对象编程的过程中, 人们不停地总结发现, 有了这四大特性, 就能更容易地实现各种面向对象的代码设计思路.
比如, 在面向对象编程的过程中, 经常会遇到 is-a 这种类关系(比如狗是一种动物), 而继承这个特性就能很好地支持这种 is-a 的代码设计思路, 并且解决代码复用的问题, 所以继承就成了面向对象编程的四大特性之一. 但是随着编程语言的不断迭代, 演化, 人们发现继承这种特性容易造成层次不清, 代码混乱, 所以很多编程语言在设计的时候就开始摒弃继承特性, 比如 Go 语言. 但是并不能因为它摒弃了继承特性, 就一刀切地认为它不是面向对象编程语言了.
实际上, 我个人觉得, 只要某种编程语言支持类或对象的语法概念, 并且以此作为组织代码的基本单元, 那就可以被粗略地认为它就是面向对象编程语言了. 至于是否有现成的语法机制, 完全地支持了面向对象编程的四大特性, 是否对四大特性有所取舍和优化, 可以不作为判定的标准. 基于此才有了前面的说法, **按照严格的定义, 很多语言都不能算得上面向对象编程语言, 但按照不严格的定义来讲, 现在流行的大部分编程语言都是面向对象编程语言. **
# 3.什么是面向对象分析和面向对象设计?
前面讲了面向对象编程(OOP), 实际上, 跟面向对象编程经常放到一块儿来讲的还有另外两个概念, 那就是面向对象分析(OOA)和面向对象设计(OOD). 面向对象分析英文缩写是 OOA, 全称是 Object Oriented Analysis; 面向对象设计的英文缩写是 OOD, 全称是 Object Oriented Design. OOA, OOD, OOP 三个连在一起就是面向对象分析, 设计, 编程(实现), 正好是面向对象软件开发要经历的三个阶段.
关于什么是面向对象编程, 前面已经讲过了. 现在再来讲一下, 什么是面向对象分析和设计. 这两个概念相对来说要简单一些. 面向对象分析与设计中的"分析"和"设计"这两个词, 完全可以从字面上去理解, 不需要过度解读, 简单类比软件开发中的需求分析, 系统设计即可. 不过你可能会说, 那为啥前面还加了个修饰词"面向对象"呢? 有什么特殊的意义吗?
之所以在前面加"面向对象"这几个字, 是因为我们是围绕着对象或类来做需求分析和设计的. 分析和设计两个阶段最终的产出是类的设计, 包括程序被拆解为哪些类, 每个类有哪些属性方法, 类与类之间如何交互等等. 它们比其他的分析和设计更加具体, 更加落地, 更加贴近编码, 更能够顺利地过渡到面向对象编程环节. 这也是面向对象分析和设计, 与其他分析和设计最大的不同点.
看到这里, 你可能会问, 那面向对象分析, 设计, 编程到底都负责做哪些工作呢? 简单点讲, 面向对象分析就是要搞清楚做什么, 面向对象设计就是要搞清楚怎么做, 面向对象编程就是将分析和设计的的结果翻译成代码的过程.
# 4.什么是UML?是否需要UML?
讲到面向对象分析, 设计, 编程, 就不得不提到另外一个概念, 那就是 UML(Unified Model Language), 统一建模语言. 很多讲解面向对象或设计模式的书籍, 常用它来画图表达面向对象或设计模式的设计思路.
实际上, UML 是一种非常复杂的东西. 它不仅仅包含常提到类图, 还有用例图, 顺序图, 活动图, 状态图, 组件图等. 在我看来, 即便仅仅使用类图, 学习成本也是很高的. 就单说类之间的关系, UML 就定义了很多种, 比如泛化, 实现, 关联, 聚合, 组合, 依赖等.
要想完全掌握, 并且熟练运用这些类之间的关系, 来画 UML 类图, 肯定要花很多的学习精力. 而且 UML 作为一种沟通工具, 即便你能完全按照 UML 规范来画类图, 可对于不熟悉的人来说, 看懂的成本也还是很高的.
所以, 从我的开发经验来说, UML 在互联网公司的项目开发中, 用处可能并不大. 为了文档化软件设计或者方便讨论软件设计, 大部分情况下随手画个不那么规范的草图, 能够达意, 方便沟通就够了, 而完全按照 UML 规范来将草图标准化, 所付出的代价是不值得的.
所以专栏中的很多类图并没有完全遵守 UML 的规范标准. 为了兼顾图的表达能力和学习成本, 我对 UML 类图规范做了简化, 并配上了详细的文字解释, 力图让你一眼就能看懂, 而非适得其反, 让图加重你的学习成本.
重点回顾
总结回顾一下, 你需要重点掌握的几个概念和知识点.
**1.什么是面向对象编程? **
面向对象编程是一种编程范式或编程风格. 它以类或对象作为组织代码的基本单元, 并将封装, 抽象, 继承, 多态四个特性, 作为代码设计和实现的基石.
**2.什么是面向对象编程语言? **
面向对象编程语言是支持类或对象的语法机制, 并有现成的语法机制, 能方便地实现面向对象编程四大特性(封装, 抽象, 继承, 多态)的编程语言.
**3.如何判定一个编程语言是否是面向对象编程语言? **
如果按照严格的的定义, 需要有现成的语法支持类, 对象, 四大特性才能叫作面向对象编程语言. 如果放宽要求的话, 只要某种编程语言支持类, 对象语法机制, 那基本上就可以说这种编程语言是面向对象编程语言了, 不一定非得要求具有所有的四大特性.
**4.面向对象编程和面向对象编程语言之间有何关系? **
面向对象编程一般使用面向对象编程语言来进行, 但是, 不用面向对象编程语言, 照样可以进行面向对象编程. 反过来讲, 即便使用面向对象编程语言, 写出来的代码也不一定是面向对象编程风格的, 也有可能是面向过程编程风格的.
**5.什么是面向对象分析和面向对象设计? **
简单点讲, 面向对象分析就是要搞清楚做什么, 面向对象设计就是要搞清楚怎么做. 两个阶段最终的产出是类的设计, 包括程序被拆解为哪些类, 每个类有哪些属性方法, 类与类之间如何交互等等.
# 05-理论二:封装,抽象,继承,多态分别可以解决哪些编程问题?
上一节简单介绍了面向对象的一些基本概念和知识点, 比如什么是面向对象编程, 什么是面向对象编程语言等等. 其中还提到, 理解面向对象编程及面向对象编程语言的关键就是理解其四大特性: 封装, 抽象, 继承, 多态. 不过, 对于这四大特性, 光知道它们的定义是不够的, 还要知道每个特性存在的意义和目的, 以及它们能解决哪些编程问题.
这里要强调一下, 对于这四大特性, 尽管大部分面向对象编程语言都提供了相应的语法机制来支持, 但不同的编程语言实现这四大特性的语法机制可能会有所不同. 所以在讲解四大特性的时候, 并不与具体某种编程语言的特定语法相挂钩, 同时, 也希望你不要局限在自己熟悉的编程语言的语法思维框架里.
# 1.封装(Encapsulation)
首先来看封装特性. 封装也叫作信息隐藏或者数据访问保护. 类通过暴露有限的访问接口, 授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据. 这句话怎么理解呢? 通过一个简单的例子来解释一下.
下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现. 在金融系统中, 会给每个用户创建一个虚拟钱包, 用来记录用户在系统中的虚拟货币量.
public class Wallet {
private String id;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
// ... 省略其他属性...
public Wallet() {
this.id = IdGenerator.getInstance().generate();
this.createTime = System.currentTimeMillis();
this.balance = BigDecimal.ZERO;
this.balanceLastModifiedTime = System.currentTimeMillis();
}
// 注意: 下面对 get 方法做了代码折叠, 是为了减少代码所占文章的篇幅
public String getId() { return this.id; }
public long getCreateTime() { return this.createTime; }
public BigDecimal getBalance() { return this.balance; }
public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime; }
public void increaseBalance(BigDecimal increasedAmount) {
if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
if (decreasedAmount.compareTo(this.balance) > 0) {
throw new InsufficientAmountException("...");
}
this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Wallet 类主要有四个属性(也可以叫作成员变量), 也就是前面定义中提到的信息或者数据. 其中, id 表示钱包的唯一编号, createTime 表示钱包创建的时间, balance 表示钱包中的余额, balanceLastModifiedTime 表示上次钱包余额变更的时间.
参照封装特性, 对钱包的这四个属性的访问方式进行了限制. 调用者只允许通过下面这六个方法来访问或者修改钱包里的数据.
- String getId()
- long getCreateTime()
- BigDecimal getBalance()
- long getBalanceLastModifiedTime()
- void increaseBalance(BigDecimal increasedAmount)
- void decreaseBalance(BigDecimal decreasedAmount)
之所以这样设计, 是因为从业务的角度来说, id, createTime 在创建钱包的时候就确定好了, 之后不应该再被改动, 所以并没有在 Wallet 类中, 暴露 id, createTime 这两个属性的任何修改方法, 比如 set 方法. 而且这两个属性的初始化设置, 对于 Wallet 类的调用者来说, 也应该是透明的, 所以在 Wallet 类的构造函数内部将其初始化设置好, 而不是通过构造函数的参数来外部赋值.
对于钱包余额 balance 这个属性, 从业务的角度来说, 只能增或者减, 不会被重新设置. 所以在 Wallet 类中, 只暴露了 increaseBalance() 和 decreaseBalance() 方法, 并没有暴露 set 方法. 对于 balanceLastModifiedTime 这个属性, 它完全是跟 balance 这个属性的修改操作绑定在一起的. 只有在 balance 修改的时候, 这个属性才会被修改. 所以把 balanceLastModifiedTime 这个属性的修改操作完全封装在了 increaseBalance() 和 decreaseBalance() 两个方法中, 不对外暴露任何修改这个属性的方法和业务细节. 这样也可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性.
对于封装这个特性, 需要编程语言本身提供一定的语法机制来支持. 这个语法机制就是访问权限控制 **. ** 例子中的 private, public 等关键字就是 Java 语言中的访问权限控制语法. private 关键字修饰的属性只能类本身访问, 可以保护其不被类之外的代码直接访问. 如果 Java 语言没有提供访问权限控制语法, 所有的属性默认都是 public 的, 那任意外部代码都可以通过类似 wallet.id=123; 这样的方式直接访问, 修改属性, 也就没办法达到隐藏信息和保护数据的目的了, 也就无法支持封装特性了.
**封装特性的定义讲完了, 再来看一下, 封装的意义是什么? 它能解决什么编程问题? **
如果对类中属性的访问不做限制, 那任何代码都可以访问, 修改类中的属性, 虽然这样看起来更加灵活, 但从另一方面来说, 过度灵活也意味着不可控, 属性可以随意被以各种奇葩的方式修改, 而且修改逻辑可能散落在代码中的各个角落, 势必影响代码的可读性, 可维护性. 比如某个同事在不了解业务逻辑的情况下, 在某段代码中"偷偷地"重设了 wallet 中的 balanceLastModifiedTime 属性, 这就会导致 balance 和 balanceLastModifiedTime 两个数据不一致.
除此之外, 类仅仅通过有限的方法暴露必要的操作, 也能提高类的易用性. 如果把类属性都暴露给类的调用者, 调用者想要正确地操作这些属性, 就势必要对业务细节有足够的了解. 而这对于调用者来说也是一种负担. 相反, 如果将属性封装起来, 暴露少许的几个必要的方法给调用者使用, 调用者就不需要了解太多背后的业务细节, 用错的概率就减少很多. 这就好比, 如果一个冰箱有很多按钮, 就要研究很长时间, 还不一定能操作正确. 相反, 如果只有几个必要的按钮, 比如开, 停, 调节温度, 你一眼就能知道该如何来操作, 而且操作出错的概率也会降低很多.
# 2.抽象(Abstraction)
封装主要讲的是如何隐藏信息, 保护数据, 而抽象讲的是如何隐藏方法的具体实现, 让调用者只需要关心方法提供了哪些功能, 并不需要知道这些功能是如何实现的.
在面向对象编程中, 常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制, 来实现抽象这一特性.
这里稍微说明一下, 在专栏中, 把编程语言提供的接口语法叫作"接口类"而不是"接口". 之所以这么做, 是因为"接口"这个词太泛化, 可以指好多概念, 比如 API 接口等, 所以用"接口类"特指编程语言提供的接口语法.
对于抽象这个特性, 举一个例子来进一步解释一下.
public interface IPictureStorage {
void savePicture(Picture picture);
Image getPicture(String pictureId);
void deletePicture(String pictureId);
void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}
public class PictureStorage implements IPictureStorage {
// ... 省略其他属性...
@Override
public void savePicture(Picture picture) { ... }
@Override
public Image getPicture(String pictureId) { ... }
@Override
public void deletePicture(String pictureId) { ... }
@Override
public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在上面的这段代码中, 利用 Java 中的 interface 接口语法来实现抽象特性. 调用者在使用图片存储功能的时候, 只需要了解 IPictureStorage 这个接口类暴露了哪些方法就可以了, 不需要去查看 PictureStorage 类里的具体实现逻辑.
实际上, 抽象这个特性是非常容易实现的, 并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持. 换句话说, 并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage), 才叫作抽象. 即便不编写 IPictureStorage 接口类, 单纯的 PictureStorage 类本身就满足抽象特性.
之所以这么说, 那是因为类的方法是通过编程语言中的"函数"这一语法机制来实现的. 通过函数包裹具体的实现逻辑, 这本身就是一种抽象. 调用者在使用函数的时候, 并不需要去研究函数内部的实现逻辑, 只需要通过函数的命名, 注释或者文档, 了解其提供了什么功能, 就可以直接使用了. 比如在使用 C 语言的 malloc() 函数的时候, 并不需要了解它的底层代码是怎么实现的.
除此之外, 在上一节还提到, 抽象有时候会被排除在面向对象的四大特性之外, 现在就来解释一下为什么.
抽象这个概念是一个非常通用的设计思想, 并不单单用在面向对象编程中, 也可以用来指导架构设计等. 而且这个特性也并不需要编程语言提供特殊的语法机制来支持, 只需要提供"函数"这一非常基础的语法机制, 就可以实现抽象特性, 所以, 它没有很强的"特异性", 有时候并不被看作面向对象编程的特性之一.
**抽象特性的定义讲完了, 再来看一下, 抽象的意义是什么? 它能解决什么编程问题? **
实际上, 如果上升一个思考层面的话, 抽象及其前面讲到的封装都是人类处理复杂性的有效手段. 在面对复杂系统的时候, 人脑能承受的信息复杂程度是有限的, 所以必须忽略掉一些非关键性的实现细节. 而抽象作为一种只关注功能点不关注实现的设计思路, 正好可以大脑过滤掉许多非必要的信息.
除此之外, 抽象作为一个非常宽泛的设计思想, 在代码设计中, 起到非常重要的指导作用. 很多设计原则都体现了抽象这种设计思想, 比如基于接口而非实现编程, 开闭原则(对扩展开放, 对修改关闭), 代码解耦(降低代码的耦合性)等.
换一个角度来考虑, 在定义(或者叫命名)类的方法的时候, 也要有抽象思维, 不要在方法定义中, 暴露太多的实现细节, 以保证在某个时间点需要改变方法的实现逻辑的时候, 不用去修改其定义. 举个简单例子, 比如 getAliyunPictureUrl() 就不是一个具有抽象思维的命名, 因为某一天如果不再把图片存储在阿里云上, 而是存储在私有云上, 那这个命名也要随之被修改. 相反, 如果定义一个比较抽象的函数, 比如叫作 getPictureUrl(), 那即便内部存储方式修改了, 也不需要修改命名.
# 3.继承(Inheritance)
继承是用来表示类之间的 is-a 关系, 比如猫是一种哺乳动物. 从继承关系上来讲, 继承可以分为两种模式, 单继承和多继承. 单继承表示一个子类只继承一个父类, 多继承表示一个子类可以继承多个父类, 比如猫既是哺乳动物, 又是爬行动物.
为了实现继承这个特性, 编程语言需要提供特殊的语法机制来支持, 比如 Java 使用 extends 关键字来实现继承, C++ 使用冒号(class B : public A), Python 使用 paraentheses(). 不过, 有些编程语言只支持单继承, 不支持多重继承, 比如 Java, PHP, C#, Ruby 等, 而有些编程语言既支持单重继承, 也支持多重继承, 比如 C++, Python, Perl 等.
**继承特性的定义讲完了, 再来看继承存在的意义是什么? 它能解决什么编程问题? **
继承最大的一个好处就是代码复用. 假如两个类有一些相同的属性和方法, 就可以将这些相同的部分, 抽取到父类中, 让两个子类继承父类. 这样两个子类就可以重用父类中的代码, 避免代码重复写多遍. 不过这一点也并不是继承所独有的, 也可以通过其他方式来解决这个代码复用的问题, 比如利用组合关系而不是继承关系.
如果再上升一个思维层面, 去思考继承这一特性, 可以这么理解: 代码中有一个猫类, 有一个哺乳动物类. 猫属于哺乳动物, 从人类认知的角度上来说, 是一种 is-a 关系. 通过继承来关联两个类, 反应真实世界中的这种关系, 非常符合人类的认知, 而且从设计的角度来说, 也有一种结构美感.
继承的概念很好理解, 也很容易使用. 不过, 过度使用继承, 继承层次过深过复杂, 就会导致代码可读性, 可维护性变差. 为了了解一个类的功能, 不仅需要查看这个类的代码, 还需要按照继承关系一层一层地往上查看"父类, 父类的父类..." 的代码. 还有, 子类和父类高度耦合, 修改父类的代码, 会直接影响到子类.
所以, 继承这个特性也是一个非常有争议的特性. 很多人觉得继承是一种反模式, 应该尽量少用, 甚至不用. 关于这个问题, 在后面讲到"多用组合少用继承"这种设计思想的时候, 会非常详细地再讲解.
# 4.多态(Polymorphism)
学习完了封装, 抽象, 继承之后, 再来看面向对象编程的最后一个特性, 多态. 多态是指, 子类可以替换父类, 在实际的代码运行过程中, 调用子类的方法实现. 对于多态这种特性, 纯文字解释不好理解, 还是看一个具体的例子.
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
protected int size = 0;
protected int capacity = DEFAULT_CAPACITY;
protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
public int size() { return this.size; }
public Integer get(int index) { return elements[index];}
//... 省略 n 多方法...
public void add(Integer e) {
ensureCapacity();
elements[size++] = e;
}
protected void ensureCapacity() {
//... 如果数组满了就扩容... 代码省略...
}
}
public class SortedDynamicArray extends DynamicArray {
@Override
public void add(Integer e) {
ensureCapacity();
for (int i = size-1; i>=0; --i) { // 保证数组中的数据有序
if (elements[i] > e) {
elements[i+1] = elements[i];
} else {
break;
}
}
elements[i+1] = e;
++size;
}
}
public class Example {
public static void test(DynamicArray dynamicArray) {
dynamicArray.add(5);
dynamicArray.add(1);
dynamicArray.add(3);
for (int i = 0; i < dynamicArray.size(); ++i) {
System.out.println(dynamicArray[i]);
}
}
public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果: 1, 3, 5
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
多态这种特性也需要编程语言提供特殊的语法机制来实现. 在上面的例子中, 用到了三个语法机制来实现多态.
- 第一个语法机制是编程语言要支持父类对象可以引用子类对象, 也就是可以将 SortedDynamicArray 传递给 DynamicArray.
- 第二个语法机制是编程语言要支持继承, 也就是 SortedDynamicArray 继承了 DynamicArray, 才能将 SortedDyamicArray 传递给 DynamicArray.
- 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法, 也就是 SortedDyamicArray 重写了 DynamicArray 中的 add() 方法.
通过这三种语法机制配合在一起, 就实现了在 test() 方法中, 子类 SortedDyamicArray 替换父类 DynamicArray, 执行子类 SortedDyamicArray 的 add() 方法, 也就是实现了多态特性.
对于多态特性的实现方式, 除了利用"继承加方法重写"这种实现方式之外, 还有其他两种比较常见的的实现方式, 一个是利用接口类语法, 另一个是利用 duck-typing 语法. 不过并不是每种编程语言都支持接口类或者 duck-typing 这两种语法机制, 比如 C++ 就不支持接口类语法, 而 duck-typing 只有一些动态语言才支持, 比如 Python, JavaScript 等.
接下来, 先来看如何利用接口类来实现多态特性. ** ** 还是先来看一段代码.
public interface Iterator {
String hasNext();
String next();
String remove();
}
public class Array implements Iterator {
private String[] data;
public String hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//... 省略其他方法...
}
public class LinkedList implements Iterator {
private LinkedListNode head;
public String hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//... 省略其他方法...
}
public class Demo {
private static void print(Iterator iterator) {
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
public static void main(String[] args) {
Iterator arrayIterator = new Array();
print(arrayIterator);
Iterator linkedListIterator = new LinkedList();
print(linkedListIterator);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
在这段代码中, Iterator 是一个接口类, 定义了一个可以遍历集合数据的迭代器. Array 和 LinkedList 都实现了接口类 Iterator. 通过传递不同类型的实现类(Array, LinkedList) 到 print(Iterator iterator) 函数中, 支持动态的调用不同的 next(), hasNext() 实现.
具体点讲就是, 当往 print(Iterator iterator) 函数传递 Array 类型的对象的时候, print(Iterator iterator) 函数就会调用 Array 的 next(), hasNext() 的实现逻辑; 当往 print(Iterator iterator) 函数传递 LinkedList 类型的对象的时候, print(Iterator iterator) 函数就会调用 LinkedList 的 next(), hasNext() 的实现逻辑.
**刚刚讲的是用接口类来实现多态特性. 现在再来看下, 如何用 duck-typing 来实现多态特性. ** 还是先来看一段代码. 这是一段 Python 代码.
class Logger:
def record(self):
print(“I write a log into file.”)
class DB:
def record(self):
print(“I insert data into db. ”)
def test(recorder):
recorder.record()
def demo():
logger = Logger()
db = DB()
test(logger)
test(db)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
从这段代码中可以发现, duck-typing 实现多态的方式非常灵活. Logger 和 DB 两个类没有任何关系, 既不是继承关系, 也不是接口和实现的关系, 但是只要它们都有定义了 record() 方法, 就可以被传递到 test() 方法中, 在实际运行的时候, 执行对应的 record() 方法.
也就是说, 只要两个类具有相同的方法, 就可以实现多态, 并不要求两个类之间有任何关系, 这就是所谓的 duck-typing, 是一些动态语言所特有的语法机制. 而像 Java 这样的静态语言, 通过继承实现多态特性, 必须要求两个类之间有继承关系, 通过接口实现多态特性, 类必须实现对应的接口.
**多态特性讲完了, 再来看, 多态特性存在的意义是什么? 它能解决什么编程问题? **
多态特性能提高代码的可扩展性和复用性. 为什么这么说? 回过头去看讲解多态特性的时候, 举的第二个代码实例(Iterator 的例子).
那个例子利用多态的特性, 仅用一个 print() 函数就可以实现遍历打印不同类型(Array, LinkedList)集合的数据. 当再增加一种要遍历打印的类型的时候, 比如 HashMap, 只需让 HashMap 实现 Iterator 接口, 重新实现自己的 hasNext(), next() 等方法就可以了, 完全不需要改动 print() 函数的代码. 所以说, 多态提高了代码的可扩展性.
如果不使用多态特性, 就无法将不同的集合类型(Array, LinkedList)传递给相同的函数(print(Iterator iterator) 函数). 需要针对每种要遍历打印的集合, 分别实现不同的 print() 函数, 比如针对 Array, 要实现 print(Array array) 函数, 针对 LinkedList, 要实现 print(LinkedList linkedList) 函数. 而利用多态特性, 只需要实现一个 print() 函数的打印逻辑, 就能应对各种集合数据的打印操作, 这显然提高了代码的复用性.
除此之外, 多态也是很多设计模式, 设计原则, 编程技巧的代码实现基础, 比如策略模式, 基于接口而非实现编程, 依赖倒置原则, 里式替换原则, 利用多态去掉冗长的 if-else 语句等等.
重点回顾
总结回顾一下, 你需要重点掌握的几个知识点.
1.关于封装特性
封装也叫作信息隐藏或者数据访问保护. 类通过暴露有限的访问接口, 授权外部仅能通过类提供的方式来访问内部信息或者数据. 它需要编程语言提供权限访问控制语法来支持, 例如 Java 中的 private, protected, public 关键字. 封装特性存在的意义, 一方面是保护数据不被随意修改, 提高代码的可维护性; 另一方面是仅暴露有限的必要接口, 提高类的易用性.
2.关于抽象特性
封装主要讲如何隐藏信息, 保护数据, 那抽象就是讲如何隐藏方法的具体实现, 让使用者只需要关心方法提供了哪些功能, 不需要知道这些功能是如何实现的. 抽象可以通过接口类或者抽象类来实现, 但也并不需要特殊的语法机制来支持. 抽象存在的意义, 一方面是提高代码的可扩展性, 维护性, 修改实现不需要改变定义, 减少代码的改动范围; 另一方面, 它也是处理复杂系统的有效手段, 能有效地过滤掉不必要关注的信息.
3.关于继承特性
继承是用来表示类之间的 is-a 关系, 分为两种模式: 单继承和多继承. 单继承表示一个子类只继承一个父类, 多继承表示一个子类可以继承多个父类. 为了实现继承这个特性, 编程语言需要提供特殊的语法机制来支持. 继承主要是用来解决代码复用的问题.
4.关于多态特性
多态是指子类可以替换父类, 在实际的代码运行过程中, 调用子类的方法实现. 多态这种特性也需要编程语言提供特殊的语法机制来实现, 比如继承, 接口类, duck-typing. 多态可以提高代码的扩展性和复用性, 是很多设计模式, 设计原则, 编程技巧的代码实现基础.
# 06-理论三:面向对象相比面向过程有哪些优势?面向过程真的过时了吗?
前两节讲了面向对象这种现在非常流行的编程范式, 或者说编程风格. 实际上, 除了面向对象之外, 被大家熟知的编程范式还有另外两种, 面向过程编程和函数式编程. 面向过程这种编程范式随着面向对象的出现, 已经慢慢退出了舞台, 而函数式编程目前还没有被广泛接受.
在专栏中, 我不会对函数式编程做讲解, 但我会花两节课的时间, 讲一下面向过程这种编程范式. 你可能会问, 既然面向对象已经成为主流的编程范式, 而面向过程已经不那么推荐使用, 那为什么又要浪费时间讲它呢?
那是因为很多人搞不清楚面向对象和面向过程的区别, 总以为使用面向对象编程语言来做开发, 就是在进行面向对象编程了. 而实际上, 他们只是在用面向对象编程语言, 编写面向过程风格的代码而已, 并没有发挥面向对象编程的优势. 这就相当于手握一把屠龙刀, 却只是把它当作一把普通的刀剑来用, 相当可惜.
所以, 我打算详细对比一下面向过程和面向对象这两种编程范式, 带你一块搞清楚下面这几个问题:
- 什么是面向过程编程与面向过程编程语言?
- 面向对象编程相比面向过程编程有哪些优势?
- 为什么说面向对象编程语言比面向过程编程语言更高级?
- 有哪些看似是面向对象实际是面向过程风格的代码?
- 在面向对象编程中, 为什么容易写出面向过程风格的代码?
- 面向过程编程和面向过程编程语言就真的无用武之地了吗?
# 1.什么是面向过程编程与面向过程编程语言?
在对比面向对象与面向过程优劣之前, 先把面向过程编程和面向过程编程语言这两个概念搞清楚.
实际上, 可以对比着面向对象编程和面向对象编程语言这两个概念, 来理解面向过程编程和面向过程编程语言. 还记得之前是如何定义面向对象编程和面向对象编程语言的吗? 再来回顾一下.
- 面向对象编程是一种编程范式或编程风格. 它以类或对象作为组织代码的基本单元, 并将封装, 抽象, 继承, 多态四个特性, 作为代码设计和实现的基石.
- 面向对象编程语言是支持类或对象的语法机制, 并有现成的语法机制, 能方便地实现面向对象编程四大特性(封装, 抽象, 继承, 多态)的编程语言.
类比面向对象编程与面向对象编程语言的定义, 对于面向过程编程和面向过程编程语言这两个概念, 我给出下面这样的定义.
- 面向过程编程也是一种编程范式或编程风格. 它以过程(可以为理解方法, 函数, 操作)作为组织代码的基本单元, 以数据(可以理解为成员变量, 属性)与方法相分离为最主要的特点. 面向过程风格是一种流程化的编程风格, 通过拼接一组顺序执行的方法来操作数据完成一项功能.
- 面向过程编程语言首先是一种编程语言. 它最大的特点是不支持类和对象两个语法概念, 不支持丰富的面向对象编程特性(比如继承, 多态, 封装), 仅支持面向过程编程.
不过, 这里必须声明一下, 就像之前讲到的, 面向对象编程和面向对象编程语言并没有官方的定义一样, 这里给出的面向过程编程和面向过程编程语言的定义, 也并不是严格的官方定义. 之所以要给出这样的定义, 只是为了跟面向对象编程及面向对象编程语言做个对比, 以方便你理解它们的区别.
定义不是很严格, 也比较抽象, 所以再用一个例子进一步解释一下. 假设有一个记录了用户信息的文本文件 users.txt, 每行文本的格式是 name&age&gender(比如, 小王 &28& 男). 希望写一个程序, 从 users.txt 文件中逐行读取用户信息, 然后格式化成 name\tage\tgender(其中, \t 是分隔符)这种文本格式, 并且按照 age 从小到达排序之后, 重新写入到另一个文本文件 formatted_users.txt 中. 针对这样一个小程序的开发, 一块来看看, 用面向过程和面向对象两种编程风格, 编写出来的代码有什么不同.
首先先来看, 用面向过程这种编程风格写出来的代码是什么样子的. 注意, 下面的代码是用 C 语言这种面向过程的编程语言来编写的.
struct User {
char name[64];
int age;
char gender[16];
};
struct User parse_to_user(char* text) {
// 将 text(“小王 &28& 男”) 解析成结构体 struct User
}
char* format_to_text(struct User user) {
// 将结构体 struct User 格式化成文本(" 小王\t28\t 男 ")
}
void sort_users_by_age(struct User users[]) {
// 按照年龄从小到大排序 users
}
void format_user_file(char* origin_file_path, char* new_file_path) {
// open files...
struct User users[1024]; // 假设最大 1024 个用户
int count = 0;
while(1) { // read until the file is empty
struct User user = parse_to_user(line);
users[count++] = user;
}
sort_users_by_age(users);
for (int i = 0; i < count; ++i) {
char* formatted_user_text = format_to_text(users[i]);
// write to new file...
}
// close files...
}
int main(char** args, int argv) {
format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
然后再来看, 用面向对象这种编程风格写出来的代码是什么样子的. 注意, 下面的代码是用 Java 来编写的.
public class User {
private String name;
private int age;
private String gender;
public User(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public static User praseFrom(String userInfoText) {
// 将 text(“小王 &28& 男”) 解析成类 User
}
public String formatToText() {
// 将类 User 格式化成文本(" 小王\t28\t 男 ")
}
}
public class UserFileFormatter {
public void format(String userFile, String formattedUserFile) {
// Open files...
List users = new ArrayList<>();
while (1) { // read until file is empty
// read from file into userText...
User user = User.parseFrom(userText);
users.add(user);
}
// sort users by age...
for (int i = 0; i < users.size(); ++i) {
String formattedUserText = user.formatToText();
// write to new file...
}
// close files...
}
}
public class MainApplication {
public static void main(Sring[] args) {
UserFileFormatter userFileFormatter = new UserFileFormatter();
userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
从上面的代码中, 可以看出, 面向过程和面向对象最基本的区别就是, 代码的组织方式不同. 面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User), 方法和数据结构的定义是分开的. 面向对象风格的代码被组织成一组类, 方法和数据结构被绑定一起, 定义在类中.
看完这个例子之后, 你可能会说, 面向对象编程和面向过程编程, 两种风格的区别就这么一点吗? 当然不是, 对于这两种编程风格的更多区别, 继续往下看.
# 2.面向对象编程相比面向过程编程有哪些优势?
刚刚介绍了面向过程编程及面向过程编程语言的定义, 并跟面向对象编程及面向对象编程语言做了一个简单对比. 接下来再来看一下, 为什么面向对象编程晚于面向过程编程出现, 却能取而代之, 成为现在主流的编程范式? 面向对象编程跟面向过程编程比起来, 到底有哪些优势?
# (1)OOP更加能够应对大规模复杂程序的开发
看了刚刚举的那个格式化文本文件的例子, 你可能会有这样的疑问, 两种编程风格实现的代码貌似差不多啊, 顶多就是代码的组织方式有点区别, 没有感觉到面向对象编程有什么明显的优势呀! 你的感觉没错. 之所以有这种感觉, 主要原因是这个例子程序比较简单, 不够复杂.
对于简单程序的开发来说, 不管是用面向过程编程风格, 还是用面向对象编程风格, 差别确实不会很大, 甚至有的时候, 面向过程的编程风格反倒更有优势. 因为需求足够简单, 整个程序的处理流程只有一条主线, 很容易被划分成顺序执行的几个步骤, 然后逐句翻译成代码, 这就非常适合采用面向过程这种面条式的编程风格来实现.
但对于大规模复杂程序的开发来说, 整个程序的处理流程错综复杂, 并非只有一条主线. 如果把整个程序的处理流程画出来的话, 会是一个网状结构. 如果再用面向过程编程这种流程化, 线性的思维方式, 去翻译这个网状结构, 去思考如何把程序拆解为一组顺序执行的方法, 就会比较吃力. 这个时候, 面向对象的编程风格的优势就比较明显了.
面向对象编程是以类为思考对象. 在进行面向对象编程的时候, 并不是一上来就去思考, 如何将复杂的流程拆解为一个一个方法, 而是采用曲线救国的策略, 先去思考如何给业务建模, 如何将需求翻译为类, 如何给类之间建立交互关系, 而完成这些工作完全不需要考虑错综复杂的处理流程. 当有了类的设计之后, 然后再像搭积木一样, 按照处理流程, 将类组装起来形成整个程序. 这种开发模式, 思考问题的方式, 能让我们在应对复杂程序开发的时候, 思路更加清晰.
除此之外, 面向对象编程还提供了一种更加清晰的, 更加模块化的代码组织方式. 比如, 我们开发一个电商交易系统, 业务逻辑复杂, 代码量很大, 可能要定义数百个函数, 数百个数据结构, 那如何分门别类地组织这些函数和数据结构, 才能不至于看起来比较凌乱呢? 类就是一种非常好的组织这些函数和数据结构的方式, 是一种将代码模块化的有效手段.
你可能会说, 像 C 语言这种面向过程的编程语言, 也可以按照功能的不同, 把函数和数据结构放到不同的文件里, 以达到给函数和数据结构分类的目的, 照样可以实现代码的模块化. 你说得没错. 只不过面向对象编程本身提供了类的概念, 强制你做这件事情, 而面向过程编程并不强求. 这也算是面向对象编程相对于面向过程编程的一个微创新吧.
实际上, 利用面向过程的编程语言照样可以写出面向对象风格的代码, 只不过可能会比用面向对象编程语言来写面向对象风格的代码, 付出的代价要高一些. 而且, 面向过程编程和面向对象编程并非完全对立的. 很多软件开发中, 尽管利用的是面向过程的编程语言, 也都有借鉴面向对象编程的一些优点.
# (2)OOP风格的代码更易复用,易扩展,易维护
在刚刚的那个例子中, 因为代码比较简单, 所以只用到到了类, 对象这两个最基本的面向对象概念, 并没有用到更加高级的四大特性, 封装, 抽象, 继承, 多态. 因此, 面向对象编程的优势其实并没有发挥出来.
面向过程编程是一种非常简单的编程风格, 并没有像面向对象编程那样提供丰富的特性. 而面向对象编程提供的封装, 抽象, 继承, 多态这些特性, 能极大地满足复杂的编程需求, 能方便我们写出更易复用, 易扩展, 易维护的代码. 为什么这么说呢? 还记得上一节课中讲到的封装, 抽象, 继承, 多态存在的意义吗? 再来简单回顾一下.
首先先来看下封装特性. 封装特性是面向对象编程相比于面向过程编程的一个最基本的区别, 因为它基于的是面向对象编程中最基本的类的概念. 面向对象编程通过类这种组织代码的方式, 将数据和方法绑定在一起, 通过访问权限控制, 只允许外部调用者通过类暴露的有限方法访问数据, 而不会像面向过程编程那样, 数据可以被任意方法随意修改. 因此, 面向对象编程提供的封装特性更有利于提高代码的易维护性.
其次, 再来看下抽象特性. 我们知道, 函数本身就是一种抽象, 它隐藏了具体的实现. 在使用函数的时候, 只需要了解函数具有什么功能, 而不需要了解它是怎么实现的. 从这一点上, 不管面向过程编程还是是面向对象编程, 都支持抽象特性. 不过, 面向对象编程还提供了其他抽象特性的实现方式. 这些实现方式是面向过程编程所不具备的, 比如基于接口实现的抽象. 基于接口的抽象, 可以在不改变原有实现的情况下, 轻松替换新的实现逻辑, 提高了代码的可扩展性.
再次, 来看下继承特性. 继承特性是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态). 如果两个类有一些相同的属性和方法, 就可以将这些相同的代码, 抽取到父类中, 让两个子类继承父类. 这样两个子类也就可以重用父类中的代码, 避免了代码重复写多遍, 提高了代码的复用性.
最后, 来看下多态特性. 基于这个特性, 在需要修改一个功能实现的时候, 可以通过实现一个新的子类的方式, 在子类中重写原来的功能逻辑, 用子类替换父类. 在实际的代码运行过程中, 调用子类新的功能逻辑, 而不是在原有代码上做修改. 这就遵从了"对修改关闭, 对扩展开放"的设计原则, 提高代码的扩展性. 除此之外, 利用多态特性, 不同的类对象可以传递给相同的方法, 执行不同的代码逻辑, 提高了代码的复用性.
所以说, 基于这四大特性, 利用面向对象编程, 可以更轻松地写出易复用, 易扩展, 易维护的代码. 当然, 不能说, 利用面向过程风格就不可以写出易复用, 易扩展, 易维护的代码, 但没有四大特性的帮助, 付出的代价可能就要高一些.
# (3)OOP语言更加人性化,更加高级,更加智能
跟二进制指令, 汇编语言, 面向过程编程语言相比, 面向对象编程语言的编程套路, 思考问题的方式, 是完全不一样的. 前三者是一种计算机思维方式, 而面向对象是一种人类的思维方式. 在用前面三种语言编程的时候, 我们是在思考, 如何设计一组指令, 告诉机器去执行这组指令, 操作某些数据, 帮我们完成某个任务. 而在进行面向对象编程时候, 我们是在思考, 如何给业务建模, 如何将真实的世界映射为类或者对象, 这让我们更加能聚焦到业务本身, 而不是思考如何跟机器打交道. 可以这么说, 越高级的编程语言离机器越"远", 离人类越"近", 越"智能".
重点回顾
一起总结回顾一下需要重点掌握的几个知识点.
**1.什么是面向过程编程? 什么是面向过程编程语言? **
实际上, 面向过程编程和面向过程编程语言并没有严格的官方定义. 理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比. 相较于面向对象编程以类为组织代码的基本单元, 面向过程编程则是以过程(或方法)作为组织代码的基本单元. 它最主要的特点就是数据和方法相分离. 相较于面向对象编程语言, 面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性, 比如继承, 多态, 封装.
**2.面向对象编程相比面向过程编程有哪些优势? **
面向对象编程相比起面向过程编程的优势主要有三个.
- 对于大规模复杂程序的开发, 程序的处理流程并非单一的一条主线, 而是错综复杂的网状结构. 面向对象编程比起面向过程编程, 更能应对这种复杂类型的程序开发.
- 面向对象编程相比面向过程编程, 具有更加丰富的特性(封装, 抽象, 继承, 多态). 利用这些特性编写出来的代码, 更加易扩展, 易复用, 易维护.
- 从编程语言跟机器打交道的方式的演进规律中, 我们可以总结出: 面向对象编程语言比起面向过程编程语言, 更加人性化, 更加高级, 更加智能.
# 07-理论四:哪些代码设计看似是面向对象,实际是面向过程的?
很多同学对面向对象编程都有误解, 总以为把所有代码都塞到类里, 自然就是在进行面向对象编程了. 实际上, 这样的认识是不正确的. 有时候, 从表面上看似是面向对象编程风格的代码, 从本质上看却是面向过程编程风格的.
所以本节结合具体的代码实例来讲一讲, 有哪些看似是面向对象, 实际上是面向过程编程风格的代码, 并且分析一下, 为什么我们很容易写出这样的代码.
# 1.哪些代码设计看似是面向对象,实际是面向过程的?
在用面向对象编程语言进行软件开发的时候, 我们有时候会写出面向过程风格的代码. 有些是有意为之, 并无不妥; 而有些是无意为之, 会影响到代码的质量. 下面就通过三个典型的代码案例展示一下, 什么样的代码看似是面向对象风格, 实际上是面向过程风格的. 希望你通过对这三个典型例子的学习, 能够做到举一反三, 在平时的开发中, 多留心一下自己编写的代码是否满足面向对象风格.
# (1)滥用getter,setter方法
在之前参与的项目开发中, 我经常看到, 有同事定义完类的属性之后, 就顺手把这些属性的 getter, setter 方法都定义上. 有些同事更加省事, 直接用 IDE 或者 Lombok 插件(如果是 Java 项目的话)自动生成所有属性的 getter, setter 方法.
当我问起, 为什么要给每个属性都定义 getter, setter 方法的时候, 他们的理由一般是, 为了以后可能会用到, 现在事先定义好, 类用起来就更加方便, 而且即便用不到这些 getter, setter 方法, 定义上它们也无伤大雅.
实际上, 这样的做法是非常不推荐的. 它违反了面向对象编程的封装特性, 相当于将面向对象编程风格退化成了面向过程编程风格. 我通过下面这个例子来解释一下这句话.
public class ShoppingCart {
private int itemsCount;
private double totalPrice;
private List<ShoppingCartItem> items = new ArrayList<>();
public int getItemsCount() {
return this.itemsCount;
}
public void setItemsCount(int itemsCount) {
this.itemsCount = itemsCount;
}
public double getTotalPrice() {
return this.totalPrice;
}
public void setTotalPrice(double totalPrice) {
this.totalPrice = totalPrice;
}
public List<ShoppingCartItem> getItems() {
return this.items;
}
public void addItem(ShoppingCartItem item) {
items.add(item);
itemsCount++;
totalPrice += item.getPrice();
}
// ... 省略其他方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在这段代码中, ShoppingCart 是一个简化后的购物车类, 有三个私有(private)属性: itemsCount, totalPrice, items. 对于 itemsCount, totalPrice 两个属性, 我们定义了它们的 getter, setter 方法. 对于 items 属性, 定义了它的 getter 方法和 addItem() 方法. 代码很简单, 理解起来不难. 那你有没有发现, 这段代码有什么问题呢?
先来看前两个属性, itemsCount 和 totalPrice. 虽然将它们定义成 private 私有属性, 但是提供了 public 的 getter, setter 方法, 这就跟将这两个属性定义为 public 公有属性, 没有什么两样了. 外部可以通过 setter 方法随意地修改这两个属性的值. 除此之外, 任何代码都可以随意调用 setter 方法, 来重新设置 itemsCount, totalPrice 属性的值, 这也会导致其跟 items 属性的值不一致.
而面向对象封装的定义是: 通过访问权限控制, 隐藏内部数据, 外部仅能通过类提供的有限的接口访问, 修改内部数据. 所以, 暴露不应该暴露的 setter 方法, 明显违反了面向对象的封装特性. 数据没有访问权限控制, 任何代码都可以随意修改它, 代码就退化成了面向过程编程风格的了.
看完了前两个属性, 再来看 items 这个属性. 对于 items 这个属性, 定义了它的 getter 方法和 addItem() 方法, 并没有定义它的 setter 方法. 这样的设计貌似看起来没有什么问题, 但实际上并不是.
对于 itemsCount 和 totalPrice 这两个属性来说, 定义一个 public 的 getter 方法, 确实无伤大雅, 毕竟 getter 方法不会修改数据. 但对于 items 属性就不一样了, 这是因为 items 属性的 getter 方法, 返回的是一个 List集合容器. 外部调用者在拿到这个容器之后, 是可以操作容器内部数据的, 也就是说, 外部代码还是能修改 items 中的数据. 比如像下面这样:
ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车
2
3
你可能会说, 清空购物车这样的功能需求看起来合情合理啊, 上面的代码没有什么不妥啊. 没错, 需求是合理的, 但是这样的代码写法, 会导致 itemsCount, totalPrice, items 三者数据不一致. 不应该将清空购物车的业务逻辑暴露给上层代码. 正确的做法应该是, 在 ShoppingCart 类中定义一个 clear() 方法, 将清空购物车的业务逻辑封装在里面, 透明地给调用者使用. ShoppingCart 类的 clear() 方法的具体代码实现如下:
public class ShoppingCart {
// ... 省略其他代码...
public void clear() {
items.clear();
itemsCount = 0;
totalPrice = 0.0;
}
}
2
3
4
5
6
7
8
你可能还会说, 我有一个需求, 需要查看购物车中都买了啥, 那这个时候, ShoppingCart 类不得不提供 items 属性的 getter 方法了, 那又该怎么办才好呢?
如果你熟悉 Java 语言, 那解决这个问题的方法还是挺简单的. 可以通过 Java 提供的 Collections.unmodifiableList() 方法, 让 getter 方法返回一个不可被修改的 UnmodifiableList 集合容器, 而这个容器类重写了 List 容器中跟修改数据相关的方法, 比如 add(), clear() 等方法. 一旦调用这些修改数据的方法, 代码就会抛出 UnsupportedOperationException 异常, 这样就避免了容器中的数据被修改. 具体的代码实现如下所示.
public class ShoppingCart {
// ... 省略其他代码...
public List<ShoppingCartItem> getItems() {
return Collections.unmodifiableList(this.items);
}
}
public class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// ... 省略其他代码...
}
ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();// 抛出 UnsupportedOperationException 异常
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
不过, 这样的实现思路还是有点问题. 因为当调用者通过 ShoppingCart 的 getItems() 获取到 items 之后, 虽然没法修改容器中的数据, 但仍然可以修改容器中每个对象(ShoppingCartItem)的数据. 听起来有点绕, 看看下面这几行代码你就明白了.
ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了 item 的价格属性
2
3
4
5
这个问题该如何解决呢? 今天就不展开来讲了. 在后面讲到设计模式的时候, 还会详细地讲到.
getter, setter 问题就讲完了, 稍微总结一下, 在设计实现类的时候, 除非真的需要, 否则, 尽量不要给属性定义 setter 方法. 除此之外, 尽管 getter 方法相对 setter 方法要安全些, 但是如果返回的是集合容器(比如例子中的 List 容器), 也要防范集合内部数据被修改的危险.
# (2)滥用全局变量和全局方法
再来看另外一个违反面向对象编程风格的例子, 那就是滥用全局变量和全局方法.
如果你是用类似 C 语言这样的面向过程的编程语言来做开发, 那对全局变量, 全局方法肯定不陌生, 甚至可以说, 在代码中到处可见. 但如果你是用类似 Java 这样的面向对象的编程语言来做开发, 全局变量和全局方法就不是很多见了.
在面向对象编程中, 常见的全局变量有单例类对象, 静态成员变量, 常量等, 常见的全局方法有静态方法. 单例类对象在全局代码中只有一份, 所以它相当于一个全局变量. 静态成员变量归属于类上的数据, 被所有的实例化对象所共享, 也相当于一定程度上的全局变量. 而常量是一种非常常见的全局变量, 比如一些代码中的配置参数, 一般都设置为常量, 放到一个 Constants 类中. 静态方法一般用来操作静态变量或者外部数据. 可以联想一下常用的各种 Utils 类, 里面的方法一般都会定义成静态方法, 可以在不用创建对象的情况下, 直接拿来使用. 静态方法将方法与数据分离, 破坏了封装特性, 是典型的面向过程风格.
在刚刚介绍的这些全局变量和全局方法中, Constants 类和 Utils 类最常用到. 现在就结合这两个几乎在每个软件开发中都会用到的类, 来深入探讨一下全局变量和全局方法的利与弊.
先来看一下, 在我过去参与的项目中, 一种常见的 Constants 类的定义方法.
public class Constants {
public static final String MYSQL_ADDR_KEY = "mysql_addr";
public static final String MYSQL_DB_NAME_KEY = "db_name";
public static final String MYSQL_USERNAME_KEY = "mysql_username";
public static final String MYSQL_PASSWORD_KEY = "mysql_password";
public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
public static final int REDIS_DEFAULT_MAX_IDLE = 50;
public static final int REDIS_DEFAULT_MIN_IDLE = 20;
public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
// ... 省略更多的常量定义...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在这段代码中, 把程序中所有用到的常量, 都集中地放到这个 Constants 类中. 不过, 定义一个如此大而全的 Constants 类, 并不是一种很好的设计思路. 为什么这么说呢? 原因主要有以下几点.
首先, 这样的设计会影响代码的可维护性.
如果参与开发同一个项目的工程师有很多, 在开发过程中, 可能都要涉及修改这个类, 比如往这个类里添加常量, 那这个类就会变得越来越大, 成百上千行都有可能, 查找修改某个常量也会变得比较费时, 而且还会增加提交代码冲突的概率.
其次, 这样的设计还会增加代码的编译时间.
当 Constants 类中包含很多常量定义的时候, 依赖这个类的代码就会很多. 那每次修改 Constants 类, 都会导致依赖它的类文件重新编译, 因此会浪费很多不必要的编译时间. 不要小看编译花费的时间, 对于一个非常大的工程项目来说, 编译一次项目花费的时间可能是几分钟, 甚至几十分钟. 而在开发过程中, 每次运行单元测试, 都会触发一次编译的过程, 这个编译时间就有可能会影响到开发效率.
最后, 这样的设计还会影响代码的复用性.
如果要在另一个项目中, 复用本项目开发的某个类, 而这个类又依赖 Constants 类. 即便这个类只依赖 Constants 类中的一小部分常量, 我们仍然需要把整个 Constants 类也一并引入, 也就引入了很多无关的常量到新的项目中.
那如何改进 Constants 类的设计呢? 这里有两种思路可以借鉴.
第一种是将 Constants 类拆解为功能更加单一的多个类, 比如跟 MySQL 配置相关的常量, 放到 MysqlConstants 类中; 跟 Redis 配置相关的常量, 放到 RedisConstants 类中. 当然, 还有一种个人觉得更好的设计思路, 那就是并不单独地设计 Constants 常量类, 而是哪个类用到了某个常量, 就把这个常量定义到这个类中. 比如, RedisConfig 类用到了 Redis 配置相关的常量, 那就直接将这些常量定义在 RedisConfig 中, 这样也提高了类设计的内聚性和代码的复用性.
讲完了 Constants 类, 再来讨论一下 Utils 类. 首先问一个问题, 为什么需要 Utils 类? Utils 类存在的意义是什么?
实际上, Utils 类的出现是基于这样一个问题背景: 如果有两个类 A 和 B, 它们要用到一块相同的功能逻辑, 为了避免代码重复, 不应该在两个类中, 将这个相同的功能逻辑, 重复地实现两遍. 这个时候该怎么办呢?
在讲面向对象特性的时候, 讲过继承可以实现代码复用. 利用继承特性, 把相同的属性和方法, 抽取出来, 定义到父类中. 子类复用父类中的属性和方法, 达到代码复用的目的. 但有的时候, 从业务含义上, A 类和 B 类并不一定具有继承关系, 比如 Crawler 类和 PageAnalyzer 类, 它们都用到了 URL 拼接和分割的功能, 但并不具有继承关系(既不是父子关系, 也不是兄弟关系). 仅仅为了代码复用, 生硬地抽象出一个父类出来, 会影响到代码的可读性. 如果不熟悉背后设计思路的同事, 发现 Crawler 类和 PageAnalyzer 类继承同一个父类, 而父类中定义的却是 URL 相关的操作, 会觉得这个代码写得莫名其妙, 理解不了.
既然继承不能解决这个问题, 就可以定义一个新的类, 实现 URL 拼接和分割的方法. 而拼接和分割两个方法, 不需要共享任何数据, 所以新的类不需要定义任何属性, 这个时候, 就可以把它定义为只包含静态方法的 Utils 类了.
实际上, 只包含静态方法不包含任何属性的 Utils 类, 是彻彻底底的面向过程的编程风格. 但这并不是说, 就要杜绝使用 Utils 类了. 实际上, 从刚刚讲的 Utils 类存在的目的来看, 它在软件开发中还是挺有用的, 能解决代码复用问题. 所以, 这里并不是说完全不能用 Utils 类, 而是说, 要尽量避免滥用, 不要不加思考地随意去定义 Utils 类.
在定义 Utils 类之前, 你要问一下自己, 真的需要单独定义这样一个 Utils 类吗? 是否可以把 Utils 类中的某些方法定义到其他类中呢? 如果在回答完这些问题之后, 你还是觉得确实有必要去定义这样一个 Utils 类, 那就大胆地去定义它吧. 因为即便在面向对象编程中, 也并不是完全排斥面向过程风格的代码.
除此之外, 类比 Constants 类的设计, 在设计 Utils 类的时候, 最好也能细化一下, 针对不同的功能, 设计不同的 Utils 类, 比如 FileUtils, IOUtils, StringUtils, UrlUtils 等, 不要设计一个过于大而全的 Utils 类.
# (3)定义数据和方法分离的类
再来看最后一种面向对象编程过程中, 常见的面向过程风格的代码. 那就是, 数据定义在一个类中, 方法定义在另一个类中. 你可能会觉得, 这么明显的面向过程风格的代码, 谁会这么写呢? 实际上, 如果你是基于 MVC 三层结构做 Web 方面的后端开发, 这样的代码你可能天天都在写.
传统的 MVC 结构分为 Model 层, Controller 层, View 层这三层. 不过, 在做前后端分离之后, 三层结构在后端开发中, 会稍微有些调整, 被分为 Controller 层, Service 层, Repository 层. Controller 层负责暴露接口给前端调用, Service 层负责核心业务逻辑, Repository 层负责数据读写. 而在每一层中, 又会定义相应的 VO(View Object), BO(Business Object), Entity. 一般情况下, VO, BO, Entity 中只会定义数据, 不会定义方法, 所有操作这些数据的业务逻辑都定义在对应的 Controller 类, Service 类, Repository 类中. 这就是典型的面向过程的编程风格.
实际上, 这种开发模式叫作基于贫血模型的开发模式, 也是现在非常常用的一种 Web 项目的开发模式. 看到这里, 你内心里应该有很多疑惑吧? 既然这种开发模式明显违背面向对象的编程风格, 为什么大部分 Web 项目都是基于这种开发模式来开发呢? 关于这个问题, 今天不打算展开讲解. 因为它跟我们平时的项目开发结合得非常紧密, 所以, 更加细致, 全面的讲解, 我把它安排在面向对象实战环节里了, 希望可以把这个问题讲透彻.
# 2.在面向对象编程中,为什么容易写出面向过程风格的代码?
我们在进行面向对象编程的时候, 很容易不由自主地就写出面向过程风格的代码, 或者说感觉面向过程风格的代码更容易写. 这是为什么呢?
可以联想一下, 在生活中, 你去完成一个任务, 一般都会思考, 应该先做什么, 后做什么, 如何一步一步地顺序执行一系列操作, 最后完成整个任务. 面向过程编程风格恰恰符合人的这种流程化思维方式. 而面向对象编程风格正好相反. 它是一种自底向上的思考方式. 它不是先去按照执行流程来分解任务, 而是将任务翻译成一个一个的小的模块(也就是类), 设计类之间的交互, 最后按照流程将类组装起来, 完成整个任务. 这样的思考路径比较适合复杂程序的开发, 但并不是特别符合人类的思考习惯.
除此之外, 面向对象编程要比面向过程编程难一些. 在面向对象编程中, 类的设计还是挺需要技巧, 挺需要一定设计经验的. 你要去思考如何封装合适的数据和方法到一个类里, 如何设计类之间的关系, 如何设计类之间的交互等等诸多设计问题.
所以, 基于这两点原因, 很多工程师在开发的过程, 更倾向于用不太需要动脑子的方式去实现需求, 也就不由自主地就将代码写成面向过程风格的了.
# 3.面向过程编程及面向过程编程语言就真的无用武之地了吗?
前面讲了面向对象编程相比面向过程编程的各种优势, 又讲了哪些代码看起来像面向对象风格, 而实际上是面向过程编程风格的. 那是不是面向过程编程风格就过时了被淘汰了呢? 是不是在面向对象编程开发中, 就要杜绝写面向过程风格的代码呢?
前面有讲到, 如果开发的是微小程序, 或者是一个数据处理相关的代码, 以算法为主, 数据为辅, 那脚本式的面向过程的编程风格就更适合一些. 当然, 面向过程编程的用武之地还不止这些. 实际上, 面向过程编程是面向对象编程的基础, 面向对象编程离不开基础的面向过程编程. 为什么这么说? 仔细想想, 类中每个方法的实现逻辑, 不就是面向过程风格的代码吗?
除此之外, 面向对象和面向过程两种编程风格, 也并不是非黑即白, 完全对立的. 在用面向对象编程语言开发的软件中, 面向过程风格的代码并不少见, 甚至在一些标准的开发库(比如 JDK, Apache Commons, Google Guava)中, 也有很多面向过程风格的代码.
不管使用面向过程还是面向对象哪种风格来写代码, 最终的目的还是写出易维护, 易读, 易复用, 易扩展的高质量代码. 只要能避免面向过程编程风格的一些弊端, 控制好它的副作用, 在掌控范围内为我们所用, 就大可不用避讳在面向对象编程中写面向过程风格的代码.
重点回顾
今天要掌握的重点内容是三种违反面向对象编程风格的典型代码设计.
1.滥用getter, setter方法
在设计实现类的时候, 除非真的需要, 否则尽量不要给属性定义 setter 方法. 除此之外, 尽管 getter 方法相对 setter 方法要安全些, 但是如果返回的是集合容器, 那也要防范集合内部数据被修改的风险.
2.Constants 类, Utils 类的设计问题
对于这两种类的设计, 尽量能做到职责单一, 定义一些细化的小类, 比如 RedisConstants, FileUtils, 而不是定义一个大而全的 Constants 类, Utils 类. 除此之外, 如果能将这些类中的属性和方法, 划分归并到其他业务类中, 那是最好不过的了, 能极大地提高类的内聚性和代码的可复用性.
3.基于贫血模型的开发模式
关于这一部分, 我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的. 这是因为数据和操作是分开定义在 VO/BO/Entity 和 Controler/Service/Repository 中的. 今天, 你只需要掌握这一点就可以了. 为什么这种开发模式如此流行? 如何规避面向过程编程的弊端? 有没有更好的可替代的开发模式? 相关的更多问题, 在面向对象实战篇中会一一讲解.
# 08-理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?
在面向对象编程中, 抽象类和接口是两个经常被用到的语法概念, 是面向对象四大特性, 以及很多设计模式, 设计思想, 设计原则编程实现的基础. 比如, 可以使用接口来实现面向对象的抽象特性, 多态特性和基于接口而非实现的设计原则, 使用抽象类来实现面向对象的继承特性和模板设计模式等等.
不过, 并不是所有的面向对象编程语言都支持这两个语法概念, 比如, C++ 这种编程语言只支持抽象类, 不支持接口; 而像 Python 这样的动态编程语言, 既不支持抽象类, 也不支持接口. 尽管有些编程语言没有提供现成的语法来支持接口和抽象类, 仍然可以通过一些手段来模拟实现这两个语法概念.
这两个语法概念不仅在工作中经常会被用到, 在面试中也经常被提及. 比如, "接口和抽象类的区别是什么? 什么时候用接口? 什么时候用抽象类? 抽象类和接口存在的意义是什么? 能解决哪些编程问题?" 等等.
# 1.什么是抽象类和接口?区别在哪里?
不同的编程语言对接口和抽象类的定义方式可能有些差别, 但差别并不会很大. Java 这种编程语言, 既支持抽象类, 也支持接口, 所以, 为了让你对这两个语法概念有比较直观的认识, 我们拿 Java 这种编程语言来举例讲解.
首先来看一下, 在 Java 这种编程语言中, 是如何定义抽象类的.
下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式). Logger 是一个记录日志的抽象类, FileLogger 和 MessageQueueLogger 继承 Logger, 分别实现两种不同的日志记录方式: 记录日志到文件中和记录日志到消息队列中. FileLogger 和 MessageQueueLogger 两个子类复用了父类 Logger 中的 name, enabled, minPermittedLevel 属性和 log() 方法, 但因为这两个子类写日志的方式不同, 它们又各自重写了父类中的 doLog() 方法.
// 抽象类
public abstract class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel) {
this.name = name;
this.enabled = enabled;
this.minPermittedLevel = minPermittedLevel;
}
public void log(Level level, String message) {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
if (!loggable) return;
doLog(level, message);
}
protected abstract void doLog(Level level, String message);
}
// 抽象类的子类: 输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;
public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
super(name, enabled, minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}
@Override
public void doLog(Level level, String mesage) {
// 格式化 level 和 message, 输出到日志文件
fileWriter.write(...);
}
}
// 抽象类的子类: 输出日志到消息中间件 (比如 kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
super(name, enabled, minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}
@Override
protected void doLog(Level level, String mesage) {
// 格式化 level 和 message, 输出到消息中间件
msgQueueClient.send(...);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
通过上面的这个例子, 来看一下, 抽象类具有哪些特性. 我总结了下面三点.
- 抽象类不允许被实例化, 只能被继承. 也就是说, 不能 new 一个抽象类的对象出来(Logger logger = new Logger(...); 会报编译错误).
- 抽象类可以包含属性和方法. 方法既可以包含代码实现(比如 Logger 中的 log() 方法), 也可以不包含代码实现(比如 Logger 中的 doLog() 方法). 不包含代码实现的方法叫作抽象方法.
- 子类继承抽象类, 必须实现抽象类中的所有抽象方法. 对应到例子代码中就是, 所有继承 Logger 抽象类的子类, 都必须重写 doLog() 方法.
**刚刚讲了如何定义抽象类, 现在再来看一下, 在 Java 这种编程语言中如何定义接口. **
// 接口
public interface Filter {
void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类: 鉴权过滤器
public class AuthencationFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//... 鉴权逻辑..
}
}
// 接口实现类: 限流过滤器
public class RateLimitFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//... 限流逻辑...
}
}
// 过滤器使用 demo
public class Application {
// filters.add(new AuthencationFilter());
// filters.add(new RateLimitFilter());
private List<Filter> filters = new ArrayList<>();
public void handleRpcRequest(RpcRequest req) {
try {
for (Filter filter : fitlers) {
filter.doFilter(req);
}
} catch(RpcException e) {
// ... 处理过滤结果...
}
// ... 省略其他处理逻辑...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
上面这段代码是一个比较典型的接口的使用场景. 通过 Java 中的 interface 关键字定义了一个 Filter 接口. AuthencationFilter 和 RateLimitFilter 是接口的两个实现类, 分别实现了对 RPC 请求鉴权和限流的过滤功能.
代码非常简洁. 结合代码, 再来看一下, 接口都有哪些特性. 我也总结了三点.
- 接口不能包含属性(也就是成员变量).
- 接口只能声明方法, 方法不能包含代码实现.
- 类实现接口的时候, 必须实现接口中声明的所有方法.
前面讲了抽象类和接口的定义, 以及各自的语法特性. 从语法特性上对比, 这两者有比较大的区别, 比如抽象类中可以定义属性, 方法的实现, 而接口中不能定义属性, 方法也不能包含代码实现等等. 除了语法特性, 从设计的角度, 两者也有比较大的区别.
抽象类实际上就是类, 只不过是一种特殊的类, 这种类不能被实例化为对象, 只能被子类继承. 我们知道, 继承关系是一种 is-a 的关系, 那抽象类既然属于类, 也表示一种 is-a 的关系. 相对于抽象类的 is-a 关系来说, 接口表示一种 has-a 关系, 表示具有某些功能. 对于接口, 有一个更加形象的叫法, 那就是协议(contract).
# 2.抽象类和接口能解决什么编程问题?
刚刚学习了抽象类和接口的定义和区别, 现在再来学习一下, 抽象类和接口存在的意义, 让你知其然知其所以然.
**首先来看一下, 为什么需要抽象类? 它能够解决什么编程问题? **
刚刚讲到, 抽象类不能实例化, 只能被继承. 而前面还讲到, 继承能解决代码复用的问题. 所以, 抽象类也是为代码复用而生的. 多个子类可以继承抽象类中定义的属性和方法, 避免在子类中, 重复编写相同的代码.
不过, 既然继承本身就能达到代码复用的目的, 而继承也并不要求父类一定是抽象类, 那我们不使用抽象类, 照样也可以实现继承和复用. 从这个角度上来讲, 貌似并不需要抽象类这种语法呀. 那抽象类除了解决代码复用的问题, 还有什么其他存在的意义吗?
还是拿之前那个打印日志的例子来讲解. 先对上面的代码做下改造. 在改造之后的代码中, Logger 不再是抽象类, 只是一个普通的父类, 删除了 Logger 中 log(), doLog() 方法, 新增了 isLoggable() 方法. FileLogger 和 MessageQueueLogger 还是继承 Logger 父类, 以达到代码复用的目的. 具体的代码如下:
// 父类: 非抽象类, 就是普通的类. 删除了 log(),doLog(), 新增了 isLoggable().
public class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel) {
//... 构造函数不变, 代码省略...
}
protected boolean isLoggable() {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
return loggable;
}
}
// 子类: 输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;
public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
//... 构造函数不变, 代码省略...
}
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化 level 和 message, 输出到日志文件
fileWriter.write(...);
}
}
// 子类: 输出日志到消息中间件 (比如 kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
//... 构造函数不变, 代码省略...
}
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化 level 和 message, 输出到消息中间件
msgQueueClient.send(...);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这个设计思路虽然达到了代码复用的目的, 但是无法使用多态特性了. 像下面这样编写代码, 就会出现编译错误, 因为 Logger 中并没有定义 log() 方法.
Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/wangzheng/access.log");
logger.log(Level.ERROR, "This is a test log message.");
2
你可能会说, 这个问题解决起来很简单啊. 在 Logger 父类中, 定义一个空的 log() 方法, 让子类重写父类的 log() 方法, 实现自己的记录日志的逻辑, 不就可以了吗?
public class Logger {
// ... 省略部分代码...
public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
// ... 省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化 level 和 message, 输出到日志文件
fileWriter.write(...);
}
}
public class MessageQueueLogger extends Logger {
// ... 省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化 level 和 message, 输出到消息中间件
msgQueueClient.send(...);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这个设计思路能用, 但是它显然没有之前通过抽象类的实现思路优雅. 为什么这么说呢? 主要有以下几点原因.
- 在 Logger 中定义一个空的方法, 会影响代码的可读性. 如果不熟悉 Logger 背后的设计思想, 代码注释又不怎么给力, 在阅读 Logger 代码的时候, 就可能对为什么定义一个空的 log() 方法而感到疑惑, 需要查看 Logger, FileLogger, MessageQueueLogger 之间的继承关系, 才能弄明白其设计意图.
- 当创建一个新的子类继承 Logger 父类的时候, 有可能会忘记重新实现 log() 方法. 之前基于抽象类的设计思路, 编译器会强制要求子类重写 log() 方法, 否则会报编译错误. 你可能会说, 我既然要定义一个新的 Logger 子类, 怎么会忘记重新实现 log() 方法呢? 我们举的例子比较简单, Logger 中的方法不多, 代码行数也很少. 但如果 Logger 有几百行, 有 n 多方法, 除非你对 Logger 的设计非常熟悉, 否则忘记重新实现 log() 方法, 也不是不可能的.
- Logger 可以被实例化, 换句话说, 可以 new 一个 Logger 出来, 并且调用空的 log() 方法. 这也增加了类被误用的风险. 当然这个问题可以通过设置私有的构造函数的方式来解决. 不过, 显然没有通过抽象类来的优雅.
**其次再来看一下, 为什么需要接口? 它能够解决什么编程问题? **
抽象类更多的是为了代码复用, 而接口就更侧重于解耦. 接口是对行为的一种抽象, 相当于一组协议或者契约, 可以联想类比一下 API 接口. 调用者只需要关注抽象的接口, 不需要了解具体的实现, 具体的实现代码对调用者透明. 接口实现了约定和实现相分离, 可以降低代码间的耦合性, 提高代码的可扩展性.
实际上, 接口是一个比抽象类应用更加广泛, 更加重要的知识点. 比如经常提到的"基于接口而非实现编程", 就是一条几乎天天会用到, 并且能极大地提高代码的灵活性, 扩展性的设计思想. 关于接口会单独再用一节课的时间, 更加详细全面的讲解, 这里就不展开了.
# 3.如何模拟抽象类和接口两个语法概念?
在前面举的例子中, 使用 Java 的接口语法实现了一个 Filter 过滤器. 不过, 如果你熟悉的是 C++ 这种编程语言, 你可能会说, C++ 只有抽象类, 并没有接口, 那从代码实现的角度上来说, 是不是就无法实现 Filter 的设计思路了呢?
实际上, 可以通过抽象类来模拟接口. 怎么来模拟呢?
先来回忆一下接口的定义: 接口中没有成员变量, 只有方法声明, 没有方法实现, 实现接口的类必须实现接口中的所有方法. 只要满足这样几点, 从设计的角度上来说, 就可以把它叫作接口. 实际上, 要满足接口的这些语法特性并不难. 在下面这段 C++ 代码中, 就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码).
class Strategy { // 用抽象类模拟接口
public:
~Strategy();
virtual void algorithm()=0;
protected:
Strategy();
};
2
3
4
5
6
7
抽象类 Strategy 没有定义任何属性, 并且所有的方法都声明为 virtual 类型(等同于 Java 中的 abstract 关键字), 这样所有的方法都不能有代码实现, 并且所有继承这个抽象类的子类, 都要实现这些方法. 从语法特性上来看, 这个抽象类就相当于一个接口.
不过, 如果你熟悉的既不是 Java, 也不是 C++, 而是现在比较流行的动态编程语言, 比如 Python, Ruby 等, 你可能还会有疑问: 在这些动态语言中, 不仅没有接口的概念, 也没有类似 abstract, virtual 这样的关键字来定义抽象类, 那该如何实现上面的讲到的 Filter, Logger 的设计思路呢? 实际上, 除了用抽象类来模拟接口之外, 还可以用普通类来模拟接口. 具体的 Java 代码实现如下所示.
public class MockInteface {
protected MockInteface() {}
public void funcA() {
throw new MethodUnSupportedException();
}
}
2
3
4
5
6
我们知道类中的方法必须包含实现, 这个不符合接口的定义. 但是可以让类中的方法抛出 MethodUnSupportedException 异常, 来模拟不包含实现的接口, 并且能强迫子类在继承这个父类的时候, 都去主动实现父类的方法, 否则就会在运行时抛出异常. 那又如何避免这个类被实例化呢? 实际上很简单, 只需要将这个类的构造函数声明为 protected 访问权限就可以了.
# 4.如何决定该用抽象类还是接口?
刚刚的讲解可能有些偏理论, 现在就从真实项目开发的角度来看一下, 在代码设计, 编程开发的时候, 什么时候该用抽象类? 什么时候该用接口?
实际上, 判断的标准很简单. 如果要表示一种 is-a 的关系, 并且是为了解决代码复用的问题, 就用抽象类; 如果要表示一种 has-a 关系, 并且是为了解决抽象而非代码复用的问题, 那就可以使用接口.
从类的继承层次上来看, 抽象类是一种自下而上的设计思路, 先有子类的代码重复, 然后再抽象成上层的父类(也就是抽象类). 而接口正好相反, 它是一种自上而下的设计思路. 在编程的时候, 一般都是先设计接口, 再去考虑具体的实现.
重点回顾
总结回顾一下需要掌握的重点内容.
1.抽象类和接口的语法特性
抽象类不允许被实例化, 只能被继承. 它可以包含属性和方法. 方法既可以包含代码实现, 也可以不包含代码实现. 不包含代码实现的方法叫作抽象方法. 子类继承抽象类, 必须实现抽象类中的所有抽象方法. 接口不能包含属性, 只能声明方法, 方法不能包含代码实现. 类实现接口的时候, 必须实现接口中声明的所有方法.
2.抽象类和接口存在的意义
抽象类是对成员变量和方法的抽象, 是一种 is-a 关系, 是为了解决代码复用问题. 接口仅仅是对方法的抽象, 是一种 has-a 关系, 表示具有某一组行为特性, 是为了解决解耦问题, 隔离接口和具体的实现, 提高代码的扩展性.
3.抽象类和接口的应用场景区别
什么时候该用抽象类? 什么时候该用接口? 实际上, 判断的标准很简单. 如果要表示一种 is-a 的关系, 并且是为了解决代码复用问题, 就用抽象类; 如果要表示一种 has-a 关系, 并且是为了解决抽象而非代码复用问题, 那就用接口.
# 09-理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?
上一节讲了接口和抽象类, 以及各种编程语言是如何支持, 实现这两个语法概念的. 本节继续讲一个跟"接口"相关的知识点: 基于接口而非实现编程. 这个原则非常重要, 是一种非常有效的提高代码质量的手段, 在平时的开发中特别经常被用到.
本节会结合一个有关图片存储的实战案例来讲解. 除此之外, 这条原则还很容易被过度应用, 比如为每一个实现类都定义对应的接口. 针对这类问题, 本节也会告诉你如何来做权衡, 怎样恰到好处地应用这条原则.
# 1.如何解读原则中的"接口"二字?
"基于接口而非实现编程" 这条原则的英文描述是: "Program to an interface, not an implementation". 理解这条原则的时候, 千万不要一开始就与具体的编程语言挂钩, 局限在编程语言的"接口"语法中(比如 Java 中的 interface 接口语法). 这条原则最早出现于 1994 年 GoF 的《设计模式》这本书, 它先于很多编程语言而诞生(比如 Java 语言), 是一条比较抽象, 泛化的设计思想.
实际上, 理解这条原则的关键, 就是理解其中的"接口"两个字. 还记得上一节讲的"接口"的定义吗? 从本质上来看, "接口" 就是一组 "协议" 或者 "约定", 是功能提供者提供给使用者的一个"功能列表" . "接口" 在不同的应用场景下会有不同的解读, 比如服务端与客户端之间的 "接口", 类库提供的 "接口", 甚至是一组通信的协议都可以叫作 "接口". 刚刚对 "接口" 的理解, 都比较偏上层, 偏抽象, 与实际的写代码离得有点远. 如果落实到具体的编码, "基于接口而非实现编程" 这条原则中的 "接口", 可以理解为编程语言中的接口或者抽象类.
前面提到, 这条原则能非常有效地提高代码质量, 之所以这么说, 那是因为, 应用这条原则, 可以将接口和实现相分离, 封装不稳定的实现, 暴露稳定的接口. 上游系统面向接口而非实现编程, 不依赖不稳定的实现细节, 这样当实现发生变化的时候, 上游系统的代码基本上不需要做改动, 以此来降低耦合性, 提高扩展性.
实际上, "基于接口而非实现编程" 这条原则的另一个表述方式, 是 "基于抽象而非实现编程". 后者的表述方式其实更能体现这条原则的设计初衷. 在软件开发中, 最大的挑战之一就是需求的不断变化, 这也是考验代码设计好坏的一个标准. 越抽象, 越顶层, 越脱离具体某一实现的设计, 越能提高代码的灵活性, 越能应对未来的需求变化. 好的代码设计, 不仅能应对当下的需求, 而且在将来需求发生变化的时候, 仍然能够在不破坏原有代码设计的情况下灵活应对 **. ** 而抽象就是提高代码扩展性, 灵活性, 可维护性最有效的手段之一.
# 2.如何将这条原则应用到实战中?
对于这条原则, 下面结合一个具体的实战案例来进一步讲解一下.
假设系统中有很多涉及图片处理和存储的业务逻辑. 图片经过处理之后被上传到阿里云上. 为了代码复用, 封装了图片存储相关的代码逻辑, 提供了一个统一的 AliyunImageStore 类, 供整个系统来使用. 具体的代码实现如下所示:
public class AliyunImageStore {
//... 省略属性, 构造函数等...
public void createBucketIfNotExisting(String bucketName) {
// ... 创建 bucket 代码逻辑...
// ... 失败会抛出异常..
}
public String generateAccessToken() {
// ... 根据 accesskey/secrectkey 等生成 access token
}
public String uploadToAliyun(Image image, String bucketName, String accessToken) {
//... 上传图片到阿里云...
//... 返回图片存储在阿里云上的地址 (url)...
}
public Image downloadFromAliyun(String url, String accessToken) {
//... 从阿里云下载图片...
}
}
// AliyunImageStore 类的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//... 省略其他无关代码...
public void process() {
Image image = ...; // 处理图片, 并封装为 Image 对象
AliyunImageStore imageStore = new AliyunImageStore(/* 省略参数 */);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
整个上传流程包含三个步骤: 创建 bucket(你可以简单理解为存储目录), 生成 access token 访问凭证, 携带 access token 上传图片到指定的 bucket 中. 代码实现非常简单, 类中的几个方法定义得都很干净, 用起来也很清晰, 乍看起来没有太大问题, 完全能满足将图片存储在阿里云的业务需求.
不过, 软件开发中唯一不变的就是变化. 过了一段时间后, 我们自建了私有云, 不再将图片存储到阿里云了, 而是将图片存储到自建私有云上. 为了满足这样一个需求的变化, 该如何修改代码呢?
需要重新设计实现一个存储图片到私有云的 PrivateImageStore 类, 并用它替换掉项目中所有的 AliyunImageStore 类对象. 这样的修改听起来并不复杂, 只是简单替换而已, 对整个代码的改动并不大. 不过, 我们经常说, "细节是魔鬼". 这句话在软件开发中特别适用. 实际上, 刚刚的设计实现方式, 就隐藏了很多容易出问题的"魔鬼细节", 一块来看看都有哪些.
新的 PrivateImageStore 类需要设计实现哪些方法, 才能在尽量最小化代码修改的情况下, 替换掉 AliyunImageStore 类呢? 这就要求必须将 AliyunImageStore 类中所定义的所有 public 方法, 在 PrivateImageStore 类中都逐一定义并重新实现一遍. 而这样做就会存在一些问题, 我总结了下面两点.
首先, AliyunImageStore 类中有些函数命名暴露了实现细节, 比如, uploadToAliyun() 和 downloadFromAliyun(). 如果开发这个功能的同事没有接口意识, 抽象思维, 那这种暴露实现细节的命名方式就不足为奇了, 毕竟最初只考虑将图片存储在阿里云上. 而把这种包含 "aliyun" 字眼的方法, 照抄到 PrivateImageStore 类中, 显然是不合适的. 如果在新类中重新命名 uploadToAliyun(), downloadFromAliyun() 这些方法, 那就意味着要修改项目中所有使用到这两个方法的代码, 代码修改量可能就会很大.
其次, 将图片存储到阿里云的流程, 跟存储到私有云的流程, 可能并不是完全一致的. 比如, 阿里云的图片上传和下载的过程中, 需要生产 access token, 而私有云不需要 access token. 一方面, AliyunImageStore 中定义的 generateAccessToken() 方法不能照抄到 PrivateImageStore 中; 另一方面, 在使用 AliyunImageStore 上传, 下载图片的时候, 代码中用到了 generateAccessToken() 方法, 如果要改为私有云的上传下载流程, 这些代码都需要做调整.
那这两个问题该如何解决呢? 解决这个问题的根本方法就是, 在编写代码的时候, 要遵从"基于接口而非实现编程"的原则, 具体来讲, 需要做到下面这 3 点.
函数的命名不能暴露任何实现细节. 比如, 前面提到的 uploadToAliyun() 就不符合要求, 应该改为去掉 aliyun 这样的字眼, 改为更加抽象的命名方式, 比如: upload().
封装具体的实现细节. 比如, 跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者. 可以对上传(或下载)流程进行封装, 对外提供一个包裹所有上传(或下载)细节的方法, 给调用者使用.
为实现类定义抽象的接口. 具体的实现类都依赖统一的接口定义, 遵从一致的上传功能协议. 使用者依赖接口, 而不是具体的实现类来编程.
按照这个思路, 把代码重构一下. 重构后的代码如下所示:
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore {
//... 省略属性, 构造函数等...
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
//... 上传图片到阿里云...
//... 返回图片在阿里云上的地址 (url)...
}
public Image download(String url) {
String accessToken = generateAccessToken();
//... 从阿里云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ... 创建 bucket...
// ... 失败会抛出异常..
}
private String generateAccessToken() {
// ... 根据 accesskey/secrectkey 等生成 access token
}
}
// 上传下载流程改变: 私有云不需要支持 access token
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
//... 上传图片到私有云...
//... 返回图片的 url...
}
public Image download(String url) {
//... 从私有云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ... 创建 bucket...
// ... 失败会抛出异常..
}
}
// ImageStore 的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//... 省略其他无关代码...
public void process() {
Image image = ...;// 处理图片, 并封装为 Image 对象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
除此之外, 很多人在定义接口的时候, 希望通过实现类来反推接口的定义. 先把实现类写好, 然后看实现类中有哪些方法, 照抄到接口定义中. 如果按照这种思考方式, 就有可能导致接口定义不够抽象, 依赖具体的实现. 这样的接口设计就没有意义了. 不过, 如果你觉得这种思考方式更加顺畅, 那也没问题, 只是将实现类的方法搬移到接口定义中的时候, 要有选择性的搬移, 不要将跟具体实现相关的方法搬移到接口中, 比如 AliyunImageStore 中的 generateAccessToken() 方法.
总结一下, 在做软件开发的时候, 一定要有抽象意识, 封装意识, 接口意识. 在定义接口的时候, 不要暴露任何实现细节. 接口的定义只表明做什么, 而不是怎么做. 而且在设计接口的时候, 要多思考一下, 这样的接口设计是否足够通用, 是否能够做到在替换具体的接口实现的时候, 不需要任何接口定义的改动.
# 3.是否需要为每个类定义接口?
看了刚刚的讲解, 你可能会有这样的疑问: 为了满足这条原则, 是不是需要给每个实现类都定义对应的接口呢? 在开发的时候, 是不是任何代码都要只依赖接口, 完全不依赖实现编程呢?
做任何事情都要讲求一个"度", 过度使用这条原则, 非得给每个类都定义接口, 接口满天飞, 也会导致不必要的开发负担. 至于什么时候, 该为某个类定义接口, 实现基于接口的编程, 什么时候不需要定义接口, 直接使用实现类编程, 做权衡的根本依据, 还是要回归到设计原则诞生的初衷上来. 只要搞清楚了这条原则是为了解决什么样的问题而产生的, 你就会发现, 很多之前模棱两可的问题, 都会变得豁然开朗.
前面也提到, 这条原则的设计初衷是, 将接口和实现相分离, 封装不稳定的实现, 暴露稳定的接口. 上游系统面向接口而非实现编程, 不依赖不稳定的实现细节, 这样当实现发生变化的时候, 上游系统的代码基本上不需要做改动, 以此来降低代码间的耦合性, 提高代码的扩展性. 从这个设计初衷上来看, 如果在业务场景中, 某个功能只有一种实现方式, 未来也不可能被其他实现方式替换, 那就没有必要为其设计接口, 也没有必要基于接口编程, 直接使用实现类就可以了.
除此之外, 越是不稳定的系统, 越是要在代码的扩展性, 维护性上下功夫. 相反, 如果某个系统特别稳定, 在开发完之后, 基本上不需要做维护, 那就没有必要为其扩展性, 投入不必要的开发时间.
重点回顾
总结回顾一下需要掌握的重点内容.
- "基于接口而非实现编程", 这条原则的另一个表述方式, 是 "基于抽象而非实现编程". 后者的表述方式其实更能体现这条原则的设计初衷. 在做软件开发的时候, 一定要有抽象意识, 封装意识, 接口意识. 越抽象, 越顶层, 越脱离具体某一实现的设计, 越能提高代码的灵活性, 扩展性, 可维护性.
- 在定义接口的时候, 一方面, 命名要足够通用, 不能包含跟具体实现相关的字眼; 另一方面, 与特定实现有关的方法不要定义在接口中.
- "基于接口而非实现编程" 这条原则, 不仅仅可以指导非常细节的编程开发, 还能指导更加上层的架构设计, 系统设计等. 比如, 服务端与客户端之间的"接口"设计, 类库的"接口"设计.
# 10-理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?
在面向对象编程中, 有一条非常经典的设计原则, 那就是: 组合优于继承, 多用组合少用继承. 为什么不推荐使用继承? 组合相比继承有哪些优势? 如何判断该用组合还是继承? 今天就围绕着这三个问题, 来详细讲解一下这条设计原则.
# 1.为什么不推荐使用继承?
继承是面向对象的四大特性之一, 用来表示类之间的 is-a 关系, 可以解决代码复用的问题. 虽然继承有诸多作用, 但继承层次过深, 过复杂, 也会影响到代码的可维护性. 所以, 对于是否应该在项目中使用继承, 网上有很多争议. 很多人觉得继承是一种反模式, 应该尽量少用, 甚至不用. 为什么会有这样的争议? 通过一个例子来解释一下.
假设要设计一个关于鸟的类. 我们将"鸟类"这样一个抽象的事物概念, 定义为一个抽象类 AbstractBird. 所有更细分的鸟, 比如麻雀, 鸽子, 乌鸦等, 都继承这个抽象类.
大部分鸟都会飞, 那可不可以在 AbstractBird 抽象类中, 定义一个 fly() 方法呢? 答案是否定的. 尽管大部分鸟都会飞, 但也有特例, 比如鸵鸟就不会飞. 鸵鸟继承具有 fly() 方法的父类, 那鸵鸟就具有 "飞" 这样的行为, 这显然不符合对现实世界中事物的认识. 当然你可能会说, 在鸵鸟这个子类中重写 fly() 方法, 让它抛出 UnSupportedMethodException 异常不就可以了吗? 具体的代码实现如下所示:
public class AbstractBird {
//... 省略其他属性和方法...
public void fly() {
//...
}
}
public class Ostrich extends AbstractBird { // 鸵鸟
//... 省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException("I can't fly.'");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
这种设计思路虽然可以解决问题, 但不够优美. 因为除了鸵鸟之外, 不会飞的鸟还有很多, 比如企鹅. 对于这些不会飞的鸟来说, 都需要重写 fly() 方法, 抛出异常. 这样的设计, 一方面徒增了编码的工作量; 另一方面也违背了之后要讲的最小知识原则(Least Knowledge Principle, 也叫最少知识原则或者迪米特法则), 暴露不该暴露的接口给外部, 增加了类使用过程中被误用的概率.
你可能又会说, 那再通过 AbstractBird 类派生出两个更加细分的抽象类: 会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird, 让会飞的鸟都继承 AbstractFlyableBird, 让不会飞的鸟, 都继承 AbstractUnFlyableBird 类, 不就可以了吗? 具体的继承关系如下图所示:

从图中可以看出, 继承关系变成了三层. 整体来讲, 目前的继承关系还比较简单, 层次比较浅, 也算是一种可以接受的设计思路. 再继续加点难度. 在刚刚这个场景中, 只关注"鸟会不会飞", 但如果还关注 "鸟会不会叫", 那这个时候, 又该如何设计类之间的继承关系呢?
是否会飞? 是否会叫? 两个行为搭配起来会产生四种情况: 会飞会叫, 不会飞会叫, 会飞不会叫, 不会飞不会叫. 如果继续沿用刚才的设计思路, 那就需要再定义四个抽象类(AbstractFlyableTweetableBird, AbstractFlyableUnTweetableBird, AbstractUnFlyableTweetableBird, AbstractUnFlyableUnTweetableBird).

如果还需要考虑 "是否会下蛋" 这样一个行为, 那估计就要组合爆炸了. 类的继承层次会越来越深, 继承关系会越来越复杂. 而这种层次很深, 很复杂的继承关系, 一方面, 会导致代码的可读性变差. 因为要搞清楚某个类具有哪些方法, 属性, 必须阅读父类的代码, 父类的父类的代码... 一直追溯到最顶层父类的代码. 另一方面, 这也破坏了类的封装特性, 将父类的实现细节暴露给了子类. 子类的实现依赖父类的实现, 两者高度耦合, 一旦父类代码修改, 就会影响所有子类的逻辑.
总之, 继承最大的问题就在于: 继承层次过深, 继承关系过于复杂会影响到代码的可读性和可维护性. 这也是为什么不推荐使用继承. 那刚刚例子中继承存在的问题, 又该如何来解决呢?
# 2.组合相比继承有哪些优势?
实际上, 可以利用组合(composition), 接口, 委托(delegation) 三个技术手段来解决刚刚继承存在的问题.
前面讲到接口的时候说过, 接口表示具有某种行为特性. 针对 "会飞" 这样一个行为特性, 可以定义一个 Flyable 接口, 只让会飞的鸟去实现这个接口. 对于会叫, 会下蛋这些行为特性, 可以类似地定义 Tweetable 接口, EggLayable 接口. 将这个设计思路翻译成 Java 代码的话, 就是下面这个样子:
public interface Flyable {
void fly();
}
public interface Tweetable {
void tweet();
}
public interface EggLayable {
void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {// 鸵鸟
//... 省略其他属性和方法...
@Override
public void tweet() { //...
}
@Override
public void layEgg() { //...
}
}
public class Sparrow impelents Flayable,Tweetable,EggLayable {// 麻雀
//... 省略其他属性和方法...
@Override
public void fly() { //...
}
@Override
public void tweet() { //...
}
@Override
public void layEgg() { //...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
不过, 我们知道, 接口只声明方法, 不定义实现. 也就是说, 每个会下蛋的鸟都要实现一遍 layEgg() 方法, 并且实现逻辑是一样的, 这就会导致代码重复的问题. 那这个问题又该如何解决呢?
可以针对三个接口再定义三个实现类, 它们分别是: 实现了 fly() 方法的 FlyAbility 类, 实现了 tweet() 方法的 TweetAbility 类, 实现了 layEgg() 方法的 EggLayAbility 类. 然后, 通过组合和委托技术来消除代码重复. 具体的代码实现如下所示:
public interface Flyable {
void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //...
}
}
// 省略 Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {// 鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); // 组合
private EggLayAbility eggLayAbility = new EggLayAbility(); // 组合
//... 省略其他属性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
继承主要有三个作用: 表示 is-a 关系, 支持多态特性, 代码复用. 而这三个作用都可以通过其他技术手段来达成. 比如 is-a 关系, 可以通过组合和接口的 has-a 关系来替代; 多态特性可以利用接口来实现; 代码复用可以通过组合和委托来实现. 所以, 从理论上讲, 通过组合, 接口, 委托三个技术手段, 完全可以替换掉继承, 在项目中不用或者少用继承关系, 特别是一些复杂的继承关系.
# 3.如何判断该用组合还是继承?
尽管鼓励多用组合少用继承, 但组合也并不是完美的, 继承也并非一无是处. 从上面的例子来看, 继承改写成组合意味着要做更细粒度的类的拆分. 这也就意味着, 要定义更多的类和接口. 类和接口的增多也就或多或少地增加代码的复杂程度和维护成本. 所以在实际的项目开发中, 还是要根据具体的情况, 来具体选择该用继承还是组合.
如果类之间的继承结构稳定(不会轻易改变), 继承层次比较浅(比如, 最多有两层继承关系), 继承关系不复杂, 就可以大胆地使用继承. 反之, 系统越不稳定, 继承层次很深, 继承关系复杂, 就尽量使用组合来替代继承.
除此之外, 还有一些设计模式会固定使用继承或者组合. 比如, 装饰者模式(decorator pattern), 策略模式(strategy pattern), 组合模式(composite pattern)等都使用了组合关系, 而模板模式(template pattern)使用了继承关系.
前面讲到继承可以实现代码复用. 利用继承特性, 把相同的属性和方法, 抽取出来, 定义到父类中. 子类复用父类中的属性和方法, 达到代码复用的目的. 但有的时候, 从业务含义上, A 类和 B 类并不一定具有继承关系. 比如, Crawler 类和 PageAnalyzer 类, 它们都用到了 URL 拼接和分割的功能, 但并不具有继承关系(既不是父子关系, 也不是兄弟关系). 仅仅为了代码复用, 生硬地抽象出一个父类出来, 会影响到代码的可读性. 如果不熟悉背后设计思路的同事, 发现 Crawler 类和 PageAnalyzer 类继承同一个父类, 而父类中定义的却只是 URL 相关的操作, 会觉得这个代码写得莫名其妙, 理解不了. 这个时候, 使用组合就更加合理, 更加灵活. 具体的代码实现如下所示:
public class Url {
//... 省略属性和方法
}
public class Crawler {
private Url url; // 组合
public Crawler() {
this.url = new Url();
}
//...
}
public class PageAnalyzer {
private Url url; // 组合
public PageAnalyzer() {
this.url = new Url();
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
还有一些特殊的场景要求必须使用继承. 如果不能改变一个函数的入参类型, 而入参又非接口, 为了支持多态, 只能采用继承来实现. 比如下面这样一段代码, 其中 FeignClient 是一个外部类, 我们没有权限去修改这部分代码, 但是希望能重写这个类在运行时执行的 encode() 函数. 这个时候, 只能采用继承来实现了.
public class FeignClient { // feighn client 框架代码
//... 省略其他代码...
public void encode(String url) {
//...
}
}
public void demofunction(FeignClient feignClient) {
//...
feignClient.encode(url);
//...
}
public class CustomizedFeignClient extends FeignClient {
@Override
public void encode(String url) { //... 重写 encode 的实现...
}
}
// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
尽管有些人说, 要杜绝继承, 100% 用组合代替继承, 但是我的观点没那么极端! 之所以 "多用组合少用继承" 这个口号喊得这么响, 只是因为长期以来, 我们过度使用继承. 还是那句话, 组合并不完美, 继承也不是一无是处. 只要控制好它们的副作用, 发挥它们各自的优势, 在不同的场合下, 恰当地选择使用继承还是组合, 这才是我们所追求的境界.
重点回顾
总结一下需要重点掌握的知识点.
**1.为什么不推荐使用继承? **
继承是面向对象的四大特性之一, 用来表示类之间的 is-a 关系, 可以解决代码复用的问题. 虽然继承有诸多作用, 但继承层次过深, 过复杂, 也会影响到代码的可维护性. 在这种情况下, 应该尽量少用, 甚至不用继承.
**2.组合相比继承有哪些优势? **
继承主要有三个作用: 表示 is-a 关系, 支持多态特性, 代码复用. 而这三个作用都可以通过组合, 接口, 委托三个技术手段来达成. 除此之外, 利用组合还能解决层次过深, 过复杂的继承关系影响代码可维护性的问题.
**3.如何判断该用组合还是继承? **
尽管鼓励多用组合少用继承, 但组合也并不是完美的, 继承也并非一无是处. 在实际的项目开发中, 还是要根据具体的情况, 来选择该用继承还是组合. 如果类之间的继承结构稳定, 层次比较浅, 关系不复杂, 就可以大胆地使用继承. 反之, 就尽量使用组合来替代继承. 除此之外, 还有一些设计模式, 特殊的应用场景, 会固定使用继承或者组合.
# 11-实战一(上):业务开发常用的基于贫血模型的MVC架构违背OOP吗?
前面几节课学习了面向对象的一些理论知识, 比如面向对象四大特性, 接口和抽象类, 面向对象和面向过程编程风格, 基于接口而非实现编程和多用组合少用继承设计思想等等. 接下来再用四节课的时间, 通过两个更加贴近实战的项目来进一步学习, 如何将这些理论应用到实际的软件开发中.
大部分工程师都是做业务开发的, 所以今天讲的这个实战项目也是一个典型的业务系统开发案例. 很多业务系统都是基于 MVC 三层架构来开发的. 实际上, 更确切点讲, 这是一种基于贫血模型的 MVC 三层架构开发模式.
虽然这种开发模式已经成为标准的 Web 项目的开发模式, 但它却违反了面向对象编程风格, 是一种彻彻底底的面向过程的编程风格, 因此而被有些人称为反模式(anti-pattern). 特别是领域驱动设计(Domain Driven Design, 简称 DDD)盛行之后, 这种基于贫血模型的传统的开发模式就更加被人诟病. 而基于充血模型的 DDD 开发模式越来越被人提倡. 所以这里用两节课的时间, 结合一个虚拟钱包系统的开发案例, 带你彻底弄清楚这两种开发模式.
考虑到你有可能不太了解这几个概念, 所以在正式进入实战项目的讲解之前, 需要先搞清楚下面几个问题:
- 什么是贫血模型? 什么是充血模型?
- 为什么说基于贫血模型的传统开发模式违反 OOP?
- 基于贫血模型的传统开发模式既然违反 OOP, 那又为什么如此流行?
- 什么情况下应该考虑使用基于充血模型的 DDD 开发模式?
# 1.什么是基于贫血模型的传统开发模式?
对于大部分的后端开发工程师来说, MVC 三层架构都不会陌生. 不过, 为了统一对 MVC 的认识, 还是一块回顾一下, 什么是 MVC 三层架构.
MVC 三层架构中的 M 表示 Model, V 表示 View, C 表示 Controller. 它将整个项目分为三层: 展示层, 逻辑层, 数据层. MVC 三层开发架构是一个比较笼统的分层方式, 落实到具体的开发层面, 很多项目也并不会 100% 遵从 MVC 固定的分层方式, 而是会根据具体的项目需求, 做适当的调整.
比如, 现在很多 Web 或者 App 项目都是前后端分离的, 后端负责暴露接口给前端调用. 这种情况下, 一般就将后端项目分为 Repository 层, Service 层, Controller 层. 其中, Repository 层负责数据访问, Service 层负责业务逻辑, Controller 层负责暴露接口. 当然, 这只是其中一种分层和命名方式. 不同的项目, 不同的团队, 可能会对此有所调整. 不过, 万变不离其宗, 只要是依赖数据库开发的 Web 项目, 基本的分层思路都大差不差.
刚刚回顾了 MVC 三层开发架构. 现在再来看一下, 什么是贫血模型?
实际上, 你可能一直都在用贫血模型做开发, 只是自己不知道而已. 不夸张地讲, 据我了解, 目前几乎所有的业务后端系统, 都是基于贫血模型的. 举一个简单的例子来解释一下.
////////// Controller+VO(View Object) //////////
public class UserController {
private UserService userService; // 通过构造函数或者 IOC 框架注入
public UserVo getUserById(Long userId) {
UserBo userBo = userService.getUserById(userId);
UserVo userVo = [...convert userBo to userVo...];
return userVo;
}
}
public class UserVo {// 省略其他属性, get/set/construct 方法
private Long id;
private String name;
private String cellphone;
}
////////// Service+BO(Business Object) //////////
public class UserService {
private UserRepository userRepository; // 通过构造函数或者 IOC 框架注入
public UserBo getUserById(Long userId) {
UserEntity userEntity = userRepository.getUserById(userId);
UserBo userBo = ...;
return userBo;
}
}
public class UserBo {// 省略其他属性, get/set/construct 方法
private Long id;
private String name;
private String cellphone;
}
////////// Repository+Entity //////////
public class UserRepository {
public UserEntity getUserById(Long userId) { //...
}
}
public class UserEntity {// 省略其他属性, get/set/construct 方法
private Long id;
private String name;
private String cellphone;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
平时开发 Web 后端项目的时候, 基本上都是这么组织代码的. 其中, UserEntity 和 UserRepository 组成了数据访问层, UserBo 和 UserService 组成了业务逻辑层, UserVo 和 UserController 在这里属于接口层.
从代码中可以发现, UserBo 是一个纯粹的数据结构, 只包含数据, 不包含任何业务逻辑. 业务逻辑集中在 UserService 中. 通过 UserService 来操作 UserBo. 换句话说, Service 层的数据和业务逻辑, 被分割为 BO 和 Service 两个类中. 像 UserBo 这样, 只包含数据, 不包含业务逻辑的类, 就叫作贫血模型 (Anemic Domain Model) . 同理, UserEntity, UserVo 都是基于贫血模型设计的. 这种贫血模型将数据与操作分离, 破坏了面向对象的封装特性, 是一种典型的面向过程的编程风格.
# 2.什么是基于充血模型的DDD开发模式?
现在再讲一下, 另外一种最近更加被推崇的开发模式: 基于充血模型的 DDD 开发模式.
首先先来看一下, 什么是充血模型?
在贫血模型中, 数据和业务逻辑被分割到不同的类中. 充血模型(Rich Domain Model)正好相反, 数据和对应的业务逻辑被封装到同一个类中. 因此, 这种充血模型满足面向对象的封装特性, 是典型的面向对象编程风格.
接下来再来看一下, 什么是领域驱动设计?
领域驱动设计, 即 DDD, 主要是用来指导如何解耦业务系统, 划分业务模块, 定义业务领域模型及其交互. 领域驱动设计这个概念并不新颖, 早在 2004 年就被提出了, 到现在已经有十几年的历史了. 不过, 它被大众熟知, 还是基于另一个概念的兴起, 那就是微服务.
除了监控, 调用链追踪, API 网关等服务治理系统的开发之外, 微服务还有另外一个更加重要的工作, 那就是针对公司的业务, 合理地做微服务拆分. 而领域驱动设计恰好就是用来指导划分服务的. 所以, 微服务加速了领域驱动设计的盛行.
不过, 我个人觉得, 领域驱动设计有点儿类似敏捷开发, SOA, PAAS 等概念, 听起来很高大上, 但实际上只值"五分钱". 即便你没有听说过领域驱动设计, 对这个概念一无所知, 只要是开发业务系统, 也或多或少都在使用它. 做好领域驱动设计的关键是, 看你对自己所做业务的熟悉程度, 而并不是对领域驱动设计这个概念本身的掌握程度. 即便你对领域驱动搞得再清楚, 但是对业务不熟悉, 也并不一定能做出合理的领域设计. 所以不要把领域驱动设计当银弹, 不要花太多的时间去过度地研究它.
实际上, 基于充血模型的 DDD 开发模式实现的代码, 也是按照 MVC 三层架构分层的. Controller 层还是负责暴露接口, Repository 层还是负责数据存取, Service 层负责核心业务逻辑. 它跟基于贫血模型的传统开发模式的区别主要在 Service 层.
在基于贫血模型的传统开发模式中, Service 层包含 Service 类和 BO 类两部分, BO 是贫血模型, 只包含数据, 不包含具体的业务逻辑. 业务逻辑集中在 Service 类中. 在基于充血模型的 DDD 开发模式中, Service 层包含 Service 类和 Domain 类两部分. Domain 就相当于贫血模型中的 BO. 不过, Domain 与 BO 的区别在于它是基于充血模型开发的, 既包含数据, 也包含业务逻辑. 而 Service 类变得非常单薄. 总结一下的话就是, 基于贫血模型的传统的开发模式, 重 Service 轻 BO; 基于充血模型的 DDD 开发模式, 轻 Service 重 Domain.
基于充血模型的 DDD 设计模式的概念, 今天只是简单地介绍了一下. 下一节会结合具体的项目, 通过代码来展示, 如何基于这种开发模式来开发一个系统.
# 3.为什么基于贫血模型的传统开发模式如此受欢迎?
前面讲过, 基于贫血模型的传统开发模式, 将数据与业务逻辑分离, 违反了 OOP 的封装特性, 实际上是一种面向过程的编程风格. 但现在几乎所有的 Web 项目, 都是基于这种贫血模型的开发模式, 甚至连 Java Spring 框架的官方 demo, 都是按照这种开发模式来编写的.
前面也讲过, 面向过程编程风格有种种弊端, 比如数据和操作分离之后, 数据本身的操作就不受限制了. 任何代码都可以随意修改数据. 既然基于贫血模型的这种传统开发模式是面向过程编程风格的, 那它又为什么会被广大程序员所接受呢? 关于这个问题, 我总结了下面三点原因.
第一点原因是, 大部分情况下, 系统业务可能都比较简单, 简单到就是基于 SQL 的 CRUD 操作, 所以根本不需要动脑子精心设计充血模型, 贫血模型就足以应付这种简单业务的开发工作. 除此之外, 因为业务比较简单, 即便使用充血模型, 那模型本身包含的业务逻辑也并不会很多, 设计出来的领域模型也会比较单薄, 跟贫血模型差不多, 没有太大意义.
第二点原因是, 充血模型的设计要比贫血模型更加有难度. 因为充血模型是一种面向对象的编程风格. 从一开始就要设计好针对数据要暴露哪些操作, 定义哪些业务逻辑. 而不是像贫血模型那样, 只需要定义数据, 之后有什么功能开发需求, 就在 Service 层定义什么操作, 不需要事先做太多设计.
第三点原因是, 思维已固化, 转型有成本. 基于贫血模型的传统开发模式经历了这么多年, 已经深得人心, 习以为常. 如果转向用充血模型, 领域驱动设计, 那势必有一定的学习成本, 转型成本. 很多人在没有遇到开发痛点的情况下, 是不愿意做这件事情的.
# 4.什么项目应该考虑使用基于充血模型的DDD开发模式?
既然基于贫血模型的开发模式已经成为了一种约定俗成的开发习惯, 那什么样的项目应该考虑使用基于充血模型的 DDD 开发模式呢?
刚刚讲到, 基于贫血模型的传统的开发模式, 比较适合业务比较简单的系统开发. 相对应的, 基于充血模型的 DDD 开发模式, 更适合业务复杂的系统开发. 比如, 包含各种利息计算模型, 还款模型等复杂业务的金融系统.
你可能会有一些疑问, 这两种开发模式, 落实到代码层面, 区别不就是一个将业务逻辑放到 Service 类中, 一个将业务逻辑放到 Domain 领域模型中吗? 为什么基于贫血模型的传统开发模式, 就不能应对复杂业务系统的开发? 而基于充血模型的 DDD 开发模式就可以呢?
实际上, 除了能看到的代码层面的区别之外(一个业务逻辑放到 Service 层, 一个放到领域模型中), 还有一个非常重要的区别, 那就是两种不同的开发模式会导致不同的开发流程. 基于充血模型的 DDD 开发模式的开发流程, 在应对复杂业务系统的开发的时候更加有优势. 为什么这么说呢? 先来回忆一下, 平时基于贫血模型的传统的开发模式, 都是怎么实现一个功能需求的.
不夸张地讲, 平时的开发, 大部分都是 SQL 驱动(SQL-Driven)的开发模式. 当接到一个后端接口的开发需求的时候, 就去看接口需要的数据对应到数据库中, 需要哪张表或者哪几张表, 然后思考如何编写 SQL 语句来获取数据. 之后就是定义 Entity, BO, VO, 然后模板式地往对应的 Repository, Service, Controller 类中添加代码.
业务逻辑包裹在一个大的 SQL 语句中, 而 Service 层可以做的事情很少. SQL 都是针对特定的业务功能编写的, 复用性差. 当要开发另一个业务功能的时候, 只能重新写个满足新需求的 SQL 语句, 这就可能导致各种长得差不多, 区别很小的 SQL 语句满天飞.
所以在这个过程中, 很少有人会应用领域模型, OOP 的概念, 也很少有代码复用意识. 对于简单业务系统来说, 这种开发方式问题不大. 但对于复杂业务系统的开发来说, 这样的开发方式会让代码越来越混乱, 最终导致无法维护.
如果在项目中, 应用基于充血模型的 DDD 的开发模式, 那对应的开发流程就完全不一样了. 在这种开发模式下, 需要事先理清楚所有的业务, 定义领域模型所包含的属性和方法. 领域模型相当于可复用的业务中间层. 新功能需求的开发, 都基于之前定义好的这些领域模型来完成.
越复杂的系统, 对代码的复用性, 易维护性要求就越高, 就越应该花更多的时间和精力在前期设计上. 而基于充血模型的 DDD 开发模式, 正好需要前期做大量的业务调研, 领域模型设计, 所以它更加适合这种复杂系统的开发.
重点回顾
一起回顾一下, 本节应该掌握的重点内容.
平时做 Web 项目的业务开发, 大部分都是基于贫血模型的 MVC 三层架构, 在专栏中把它称为传统的开发模式. 之所以称之为"传统", 是相对于新兴的基于充血模型的 DDD 开发模式来说的. 基于贫血模型的传统开发模式, 是典型的面向过程的编程风格. 相反, 基于充血模型的 DDD 开发模式, 是典型的面向对象的编程风格.
不过, DDD 也并非银弹. 对于业务不复杂的系统开发来说, 基于贫血模型的传统开发模式简单够用, 基于充血模型的 DDD 开发模式有点大材小用, 无法发挥作用. 相反, 对于业务复杂的系统开发来说, 基于充血模型的 DDD 开发模式, 因为前期需要在设计上投入更多时间和精力, 来提高代码的复用性和可维护性, 所以相比基于贫血模型的开发模式, 更加有优势.
# 12-实战一(下):如何利用基于充血模型的DDD开发一个虚拟钱包系统?
上一节做了一些理论知识的铺垫性讲解, 讲到了两种开发模式, 基于贫血模型的传统开发模式, 以及基于充血模型的 DDD 开发模式. 今天正式进入实战环节, 看如何分别用这两种开发模式, 设计实现一个钱包系统.
# 1.钱包业务背景介绍
很多具有支付, 购买功能的应用(比如淘宝, 滴滴出行, 极客时间等)都支持钱包的功能. 应用为每个用户开设一个系统内的虚拟钱包账户, 支持用户充值, 提现, 支付, 冻结, 透支, 转赠, 查询账户余额, 查询交易流水等操作. 下图是一张典型的钱包功能界面.

一般来讲, 每个虚拟钱包账户都会对应用户的一个真实的支付账户, 有可能是银行卡账户, 也有可能是三方支付账户(比如支付宝, 微信钱包). 为了方便后续的讲解, 就限定钱包暂时只支持充值, 提现, 支付, 查询余额, 查询交易流水这五个核心的功能, 其他比如冻结, 透支, 转赠等不常用的功能, 暂不考虑. 为了理解这五个核心功能是如何工作的, 接下来看下它们的业务实现流程.
(1)充值
用户通过三方支付渠道, 把自己银行卡账户内的钱, 充值到虚拟钱包账号中. 这整个过程, 可以分解为三个主要的操作流程: 第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户; 第二个操作是将用户的充值金额加到虚拟钱包余额上; 第三个操作是记录刚刚这笔交易流水.

(2)支付
用户用钱包内的余额, 支付购买应用内的商品. 实际上, 支付的过程就是一个转账的过程, 从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上, 然后触发真正的银行转账操作, 从应用的公共银行账户转钱到商家的银行账户(注意, 这里并不是从用户的银行账户转钱到商家的银行账户). 除此之外, 也需要记录这笔支付的交易流水信息.

(3)提现
除了充值, 支付之外, 用户还可以将虚拟钱包中的余额, 提现到自己的银行卡中. 这个过程实际上就是扣减用户虚拟钱包中的余额, 并且触发真正的银行转账操作, 从应用的公共银行账户转钱到用户的银行账户. 同样, 也需要记录这笔提现的交易流水信息.

(4)查询余额
查询余额功能比较简单, 看一下虚拟钱包中的余额数字即可.
(5)查询交易流水
查询交易流水也比较简单. 只支持三种类型的交易流水: 充值, 支付, 提现. 在用户充值, 支付, 提现的时候, 会记录相应的交易信息. 在需要查询的时候, 只需要将之前记录的交易流水, 按照时间, 类型等条件过滤之后, 显示出来即可.
# 2.钱包系统的设计思路
根据刚刚讲的业务实现流程和数据流转图, 可以把整个钱包系统的业务划分为两部分, 其中一部分单纯跟应用内的虚拟钱包账户打交道, 另一部分单纯跟银行账户打交道. 基于这样一个业务划分, 给系统解耦, 将整个钱包系统拆分为两个子系统: 虚拟钱包系统和三方支付系统.

为了能在有限的篇幅内, 将今天的内容讲透彻, 接来下只聚焦于虚拟钱包系统的设计与实现.
**现在来看下, 如果要支持钱包的这五个核心功能, 虚拟钱包系统需要对应实现哪些操作. ** 我画了一张图, 列出了这五个功能都会对应虚拟钱包的哪些操作. 注意, 交易流水的记录和查询, 我暂时在图中打了个问号, 那是因为这块比较特殊, 待会再讲.

从图中可以看出, 虚拟钱包系统要支持的操作非常简单, 就是余额的加加减减. 其中, 充值, 提现, 查询余额三个功能, 只涉及一个账户余额的加减操作, 而支付功能涉及两个账户的余额加减操作: 一个账户减余额, 另一个账户加余额.
**再来看一下图中问号的那部分, 也就是交易流水该如何记录和查询? ** 先来看一下, 交易流水都需要包含哪些信息. 我觉得下面这几个信息是必须包含的.

从图中可以发现, 交易流水的数据格式包含两个钱包账号, 一个是入账钱包账号, 一个是出账钱包账号. 为什么要有两个账号信息呢? 这主要是为了兼容支付这种涉及两个账户的交易类型. 不过, 对于充值, 提现这两种交易类型来说, 只需要记录一个钱包账户信息就够了, 所以这样的交易流水数据格式的设计稍微有点浪费存储空间.
实际上, 还有另外一种交易流水数据格式的设计思路, 可以解决这个问题. 把"支付"这个交易类型, 拆为两个子类型: 支付和被支付. 支付单纯表示出账, 余额扣减, 被支付单纯表示入账, 余额增加. 这样在设计交易流水数据格式的时候, 只需要记录一个账户信息即可. 我画了一张两种交易流水数据格式的对比图, 可以对比着看一下.

**那以上两种交易流水数据格式的设计思路, 哪一个更好呢? **
答案是第一种设计思路更好些. 因为交易流水有两个功能: 一个是业务功能, 比如提供用户查询交易流水信息; 另一个是非业务功能, 保证数据的一致性. 这里主要是指支付操作数据的一致性.
支付实际上就是一个转账的操作, 在一个账户上加上一定的金额, 在另一个账户上减去相应的金额. 需要保证加金额和减金额这两个操作, 要么都成功, 要么都失败. 如果一个成功, 一个失败, 就会导致数据的不一致, 一个账户明明减掉了钱, 另一个账户却没有收到钱.
保证数据一致性的方法有很多, 比如依赖数据库事务的原子性, 将两个操作放在同一个事务中执行. 但这样的做法不够灵活, 因为有可能做了分库分表, 支付涉及的两个账户可能存储在不同的库中, 无法直接利用数据库本身的事务特性, 在一个事务中执行两个账户的操作. 当然还有一些支持分布式事务的开源框架, 但为了保证数据的强一致性, 它们的实现逻辑一般都比较复杂, 本身的性能也不高, 会影响业务的执行时间. 所以更加权衡的一种做法就是, 不保证数据的强一致性, 只实现数据的最终一致性, 也就是刚刚提到的交易流水要实现的非业务功能.
对于支付这样的类似转账的操作, 在操作两个钱包账户余额之前, 先记录交易流水, 并且标记为 "待执行", 当两个钱包的加减金额都完成之后, 再回过头来, 将交易流水标记为 "成功". 在给两个钱包加减金额的过程中, 如果有任意一个操作失败, 就将交易记录的状态标记为"失败". 通过后台补漏 Job, 拉取状态为"失败"或者长时间处于"待执行"状态的交易记录, 重新执行或者人工介入处理.
如果选择第二种交易流水的设计思路, 使用两条交易流水来记录支付操作, 那记录两条交易流水本身又存在数据的一致性问题, 有可能入账的交易流水记录成功, 出账的交易流水信息记录失败. 所以权衡利弊, 选择第一种稍微有些冗余的数据格式设计思路.
**现在, 再思考这样一个问题: 充值, 提现, 支付这些业务交易类型, 是否应该让虚拟钱包系统感知? 换句话说, 是否应该在虚拟钱包系统的交易流水中记录这三种类型? **
答案是否定的. 虚拟钱包系统不应该感知具体的业务交易类型. 前面讲到, 虚拟钱包支持的操作, 仅仅是余额的加加减减操作, 不涉及复杂业务概念, 职责单一, 功能通用. 如果耦合太多业务概念到里面, 势必影响系统的通用性, 而且还会导致系统越做越复杂. 因此不希望将充值, 支付, 提现这样的业务概念添加到虚拟钱包系统中.
但是, **如果不在虚拟钱包系统的交易流水中记录交易类型, 那在用户查询交易流水的时候, 如何显示每条交易流水的交易类型呢? **
从系统设计的角度, 不应该在虚拟钱包系统的交易流水中记录交易类型. 从产品需求的角度来说, 又必须记录交易流水的交易类型. 听起来比较矛盾, 这个问题该如何解决呢?
可以通过记录两条交易流水信息的方式来解决. 前面讲到, 整个钱包系统分为两个子系统, 上层钱包系统的实现, 依赖底层虚拟钱包系统和三方支付系统. 对于钱包系统来说, 它可以感知充值, 支付, 提现等业务概念, 所以在钱包系统这一层额外再记录一条包含交易类型的交易流水信息, 而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息.
为了更好地理解刚刚的设计思路, 我画了一张图.

通过查询上层钱包系统的交易流水信息, 去满足用户查询交易流水的功能需求, 而虚拟钱包中的交易流水就只是用来解决数据一致性问题. 实际上, 它的作用还有很多, 比如用来对账等. 这里就不展开讲了.
整个虚拟钱包的设计思路到此讲完了. 接下来来看一下, 如何分别用基于贫血模型的传统开发模式和基于充血模型的 DDD 开发模式, 来实现这样一个虚拟钱包系统?
# 3.基于贫血模型的传统开发模式
实际上, 如果你有一定 Web 项目的开发经验, 并且听明白了刚刚讲的设计思路, 那利用基于贫血模型的传统开发模式来实现这样一个系统, 应该是一件挺简单的事情. 这是一个典型的 Web 后端项目的三层结构. 其中 Controller 和 VO 负责暴露接口, 具体的代码实现如下所示. 注意, Controller 中, 接口实现比较简单, 主要就是调用 Service 的方法, 所以省略了具体的代码实现.
public class VirtualWalletController {
// 通过构造函数或者 IOC 框架注入
private VirtualWalletService virtualWalletService;
public BigDecimal getBalance(Long walletId) { ...} // 查询余额
public void debit(Long walletId, BigDecimal amount) { ...} // 出账
public void credit(Long walletId, BigDecimal amount) { ...} // 入账
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} // 转账
}
2
3
4
5
6
7
8
Service 和 BO 负责核心业务逻辑, Repository 和 Entity 负责数据存取. Repository 这一层的代码实现比较简单, 不是讲解的重点, 所以也省略掉了. Service 层的代码如下所示. 注意这里省略了一些不重要的校验代码, 比如对 amount 是否小于 0, 钱包是否存在的校验等等.
public class VirtualWalletBo {
private Long id;
private Long createTime;
private BigDecimal balance;
}
public class VirtualWalletService {
// 通过构造函数或者 IOC 框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWalletBo getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWalletBo walletBo = convert(walletEntity);
return walletBo;
}
public BigDecimal getBalance(Long walletId) {
return virtualWalletRepo.getBalance(walletId);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if (balance.compareTo(amount) < 0) {
throw new NoSufficientBalanceException(...);
}
walletRepo.updateBalance(walletId, balance.subtract(amount));
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletRepo.updateBalance(walletId, balance.add(amount));
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionRepo.saveTransaction(transactionEntity);
try {
debit(fromWalletId, amount);
credit(toWalletId, amount);
} catch (InsufficientBalanceException e) {
transactionRepo.updateStatus(transactionId, Status.CLOSED);
// ...rethrow exception e...
} catch (Exception e) {
transactionRepo.updateStatus(transactionId, Status.FAILED);
// ...rethrow exception e...
}
transactionRepo.updateStatus(transactionId, Status.EXECUTED);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
以上便是利用基于贫血模型的传统开发模式来实现的虚拟钱包系统. 尽管对代码稍微做了简化, 但整体的业务逻辑就是上面这样子. 其中大部分代码逻辑都非常简单, 最复杂的是 Service 中的 transfer() 转账函数. 为了保证转账操作的数据一致性, 添加了一些跟 transaction 相关的记录和状态更新的代码.
# 4.基于充血模型的DDD开发模式
现在再来看一下, 如何利用基于充血模型的 DDD 开发模式来实现这个系统? 前面讲到, 基于充血模型的 DDD 开发模式, 跟基于贫血模型的传统开发模式的主要区别就在 Service 层, Controller 层和 Repository 层的代码基本上相同. 所以重点看一下, Service 层按照基于充血模型的 DDD 开发模式该如何来实现.
在这种开发模式下, 把虚拟钱包 VirtualWallet 类设计成一个充血的 Domain 领域模型, 并且将原来在 Service 类中的部分业务逻辑移动到 VirtualWallet 类中, 让 Service 类的实现依赖 VirtualWallet 类. 具体的代码实现如下所示:
public class VirtualWallet { // Domain 领域模型 (充血模型)
private Long id;
private Long createTime = System.currentTimeMillis();
;
private BigDecimal balance = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public BigDecimal balance() {
return this.balance;
}
public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
public class VirtualWalletService {
// 通过构造函数或者 IOC 框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWallet getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}
public BigDecimal getBalance(Long walletId) {
return virtualWalletRepo.getBalance(walletId);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//... 跟基于贫血模型的传统开发模式的代码一样...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
看了上面的代码, 你可能会说, 领域模型 VirtualWallet 类很单薄, 包含的业务逻辑很简单. 相对于原来的贫血模型的设计思路, 这种充血模型的设计思路, 貌似并没有太大优势. 你说得没错! 这也是大部分业务系统都使用基于贫血模型开发的原因. 不过, 如果虚拟钱包系统需要支持更复杂的业务逻辑, 那充血模型的优势就显现出来了. 比如, 要支持透支一定额度和冻结部分余额的功能. 这个时候, 重新来看一下 VirtualWallet 类的实现代码.
public class VirtualWallet {
private Long id;
private Long createTime = System.currentTimeMillis();
;
private BigDecimal balance = BigDecimal.ZERO;
private boolean isAllowedOverdraft = true;
private BigDecimal overdraftAmount = BigDecimal.ZERO;
private BigDecimal frozenAmount = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public void freeze(BigDecimal amount) { // ...
}
public void unfreeze(BigDecimal amount) { // ...
}
public void increaseOverdraftAmount(BigDecimal amount) { //...
}
public void decreaseOverdraftAmount(BigDecimal amount) { //...
}
public void closeOverdraft() { //...
}
public void openOverdraft() { //...
}
public BigDecimal balance() {
return this.balance;
}
public BigDecimal getAvaliableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvaliableBalance();
if (totoalAvaliableBalance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
领域模型 VirtualWallet 类添加了简单的冻结和透支逻辑之后, 功能看起来就丰富了很多, 代码也没那么单薄了. 如果功能继续演进, 可以增加更加细化的冻结策略, 透支策略, 支持钱包账号(VirtualWallet id 字段)自动生成的逻辑(不是通过构造函数经外部传入 ID, 而是通过分布式 ID 生成算法来自动生成 ID)等等. VirtualWallet 类的业务逻辑会变得越来越复杂, 也就很值得设计成充血模型了.
# 5.辩证思考与灵活应用
对于虚拟钱包系统的设计与两种开发模式的代码实现, 我想你应该有个比较清晰的了解了. 不过, 我觉得还有两个问题值得讨论一下.
**第一个要讨论的问题是: 在基于充血模型的 DDD 开发模式中, 将业务逻辑移动到 Domain 中, Service 类变得很薄, 但在代码设计与实现中, 并没有完全将 Service 类去掉, 这是为什么? 或者说, Service 类在这种情况下担当的职责是什么? 哪些功能逻辑会放到 Service 类中? **
区别于 Domain 的职责, Service 类主要有下面这样几个职责.
- Service 类负责与 Repository 交流. 在设计与代码实现中, VirtualWalletService 类负责与 Repository 层打交道, 调用 Respository 类的方法, 获取数据库中的数据, 转化成领域模型 VirtualWallet, 然后由领域模型 VirtualWallet 来完成业务逻辑, 最后调用 Repository 类的方法, 将数据存回数据库. 这里再稍微解释一下, 之所以让 VirtualWalletService 类与 Repository 打交道, 而不是让领域模型 VirtualWallet 与 Repository 打交道, 那是因为想保持领域模型的独立性, 不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring, MyBatis)耦合在一起, 将流程性的代码逻辑(比如从 DB 中取数据, 映射数据)与领域模型的业务逻辑解耦, 让领域模型更加可复用.
- Service 类负责跨领域模型的业务聚合功能. VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作, 因此这部分业务逻辑无法放到 VirtualWallet 类中, 所以暂且把转账业务放到 VirtualWalletService 类中了. 当然, 虽然功能演进, 使得转账业务变得复杂起来之后, 也可以将转账业务抽取出来, 设计成一个独立的领域模型.
- Service 类负责一些非功能性及与三方系统交互的工作. 比如幂等, 事务, 发邮件, 发消息, 记录日志, 调用其他系统的 RPC 接口等, 都可以放到 Service 类中.
**第二个要讨论问题是: 在基于充血模型的 DDD 开发模式中, 尽管 Service 层被改造成了充血模型, 但是 Controller 层和 Repository 层还是贫血模型, 是否有必要也进行充血领域建模呢? **
答案是没有必要. Controller 层主要负责接口的暴露, Repository 层主要负责与数据库打交道, 这两层包含的业务逻辑并不多, 前面也提到了, 如果业务逻辑比较简单, 就没必要做充血建模, 即便设计成充血模型, 类也非常单薄, 看起来也很奇怪.
尽管这样的设计是一种面向过程的编程风格, 但只要控制好面向过程编程风格的副作用, 照样可以开发出优秀的软件. 那这里的副作用怎么控制呢?
就拿 Repository 的 Entity 来说, 即便它被设计成贫血模型, 违反面相对象编程的封装特性, 有被任意代码修改数据的风险, 但 Entity 的生命周期是有限的. 一般来讲, 把它传递到 Service 层之后, 就会转化成 BO 或者 Domain 来继续后面的业务逻辑. Entity 的生命周期到此就结束了, 所以也并不会被到处任意修改.
再来说说 Controller 层的 VO. 实际上 VO 是一种 DTO(Data Transfer Object, 数据传输对象). 它主要是作为接口的数据传输承载体, 将数据发送给其他系统. 从功能上来讲, 它理应不包含业务逻辑, 只包含数据. 所以将它设计成贫血模型也是比较合理的.
重点回顾
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比, 主要区别在 Service 层. 在基于充血模型的开发模式下, 将部分原来在 Service 类中的业务逻辑移动到了一个充血的 Domain 领域模型中, 让 Service 类的实现依赖这个 Domain 类.
在基于充血模型的 DDD 开发模式下, Service 类并不会完全移除, 而是负责一些不适合放在 Domain 类中的功能. 比如, 负责与 Repository 层打交道, 跨领域模型的业务聚合功能, 幂等事务等非功能性的工作.
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比, Controller 层和 Repository 层的代码基本上相同. 这是因为, Repository 层的 Entity 生命周期有限, Controller 层的 VO 只是单纯作为一种 DTO. 两部分的业务逻辑都不会太复杂. 业务逻辑主要集中在 Service 层. 所以, Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的.
# 13-实战二(上):如何对接口鉴权这样一个功能开发做面向对象分析?
面向对象分析(OOA), 面向对象设计(OOD), 面向对象编程(OOP) , 是面向对象开发的三个主要环节. 在前面的章节中, 对三者的讲解比较偏理论, 偏概括性, 目的是让你先有一个宏观的了解, 知道什么是 OOA, OOD, OOP. 不过, 光知道"是什么"是不够的, 更重要的还是要知道"如何做", 也就是如何进行面向对象分析, 设计与编程.
很多工程师, 特别是初级工程师, 本身没有太多的项目经验, 或者参与的项目都是基于开发框架填写 CRUD 模板似的代码, 导致分析, 设计能力比较欠缺. 当他们拿到一个比较笼统的开发需求的时候, 往往不知道从何入手.
对于 "如何做需求分析, 如何做职责划分? 需要定义哪些类? 每个类应该具有哪些属性, 方法? 类与类之间该如何交互? 如何组装类成一个可执行的程序? " 等等诸多问题, 都没有清晰的思路, 更别提利用成熟的设计原则, 思想或者设计模式, 开发出具有高内聚低耦合, 易扩展, 易读等优秀特性的代码了.
所以这里用两节课的时间, 结合一个真实的开发案例, 从基础的需求分析, 职责划分, 类的定义, 交互, 组装运行讲起, 将最基础的面向对象分析, 设计, 编程的套路给你讲清楚, 为后面学习设计原则, 设计模式打好基础.
# 1.案例介绍和难点剖析
假设, 你正在参与开发一个微服务. 微服务通过 HTTP 协议暴露接口给其他系统调用. 有一天, 你的 leader 找到你说, "为了保证接口调用的安全性, 需要设计实现一个接口调用鉴权功能, 只有经过认证之后的系统才能调用接口, 没有认证过的系统调用接口会被拒绝."
该如何来做呢? 有没有脑子里一团浆糊, 一时间无从下手的感觉呢? 为什么会有这种感觉呢? 我个人觉得主要有下面两点原因.
(1)需求不明确
leader 给到的需求过于模糊, 笼统, 不够具体, 细化, 离落地到设计, 编码还有一定的距离. 而人的大脑不擅长思考这种过于抽象的问题. 这也是真实的软件开发区别于应试教育的地方. 应试教育中的考试题目, 一般都是一个非常具体的问题, 去解答就好了. 而真实的软件开发中, 需求几乎都不是很明确.
前面讲过, 面向对象分析主要的分析对象是 "需求" , 因此面向对象分析可以粗略地看成 "需求分析". 实际上, 不管是需求分析还是面向对象分析, 首先要做的都是将笼统的需求细化到足够清晰, 可执行. 需要通过沟通, 挖掘, 分析, 假设, 梳理, 搞清楚具体的需求有哪些, 哪些是现在要做的, 哪些是未来可能要做的, 哪些是不用考虑做的.
(2)缺少锻炼
相比单纯的业务 CRUD 开发, 鉴权这个开发任务, 要更有难度. 鉴权作为一个跟具体业务无关的功能, 完全可以把它开发成一个独立的框架, 集成到很多业务系统中. 而作为被很多系统复用的通用框架, 比起普通的业务代码, 对框架的代码质量要求要更高.
开发这样通用的框架, 对工程师的需求分析能力, 设计能力, 编码能力, 甚至逻辑思维能力的要求, 都是比较高的. 如果你平时做的都是简单的 CRUD 业务开发, 那这方面的锻炼肯定不会很多, 所以一旦遇到这种开发需求, 很容易因为缺少锻炼, 脑子放空, 不知道从何入手, 完全没有思路.
# 2.对案例进行需求分析
实际上, 需求分析的工作很琐碎, 也没有太多固定的章法可寻, 所以, 我不打算很牵强地罗列那些听着有用, 实际没用的方法论, 而是希望通过鉴权这个例子, 来展示一下, 面对需求分析的时候, 我的完整的思考路径是什么样的.
尽管针对框架, 组件, 类库等非业务系统的开发, 我们一定要有组件化意识, 框架意识, 抽象意识, 开发出来的东西要足够通用, 不能局限于单一的某个业务需求, 但这并不代表可以脱离具体的应用场景, 闷头拍脑袋做需求分析. 多跟业务团队聊聊天, 甚至自己去参与几个业务系统的开发, 只有这样, 才能真正知道业务系统的痛点, 才能分析出最有价值的需求. 不过, 针对鉴权这一功能的开发, 最大的需求方还是自己, 所以, 也可以先从满足自己系统的需求开始, 然后再迭代优化.
现在来看一下, 针对鉴权这个功能的开发, 该如何做需求分析?
实际上, 这跟做算法题类似, 先从最简单的方案想起, 然后再优化. 所以我把整个的分析过程分为了循序渐进的四轮. 每一轮都是对上一轮的迭代优化, 最后形成一个可执行, 可落地的需求列表.
第一轮基础分析
对于如何做鉴权这样一个问题, 最简单的解决方案就是, 通过用户名加密码来做认证. 给每个允许访问服务的调用方, 派发一个应用名(或者叫应用 ID, AppID)和一个对应的密码(或者叫秘钥). 调用方每次进行接口请求的时候, 都携带自己的 AppID 和密码. 微服务在接收到接口调用请求之后, 会解析出 AppID 和密码, 跟存储在微服务端的 AppID 和密码进行比对. 如果一致, 说明认证成功, 则允许接口调用请求; 否则, 就拒绝接口调用请求.
第二轮分析优化
不过, 这样的验证方式, 每次都要明文传输密码. 密码很容易被截获, 是不安全的. 那如果借助加密算法(比如 SHA), 对密码进行加密之后, 再传递到微服务端验证, 是不是就可以了呢? 实际上, 这样也是不安全的, 因为加密之后的密码及 AppID, 照样可以被未认证系统(或者说黑客)截获, 未认证系统可以携带这个加密之后的密码以及对应的 AppID, 伪装成已认证系统来访问接口. 这就是典型的"重放攻击".
提出问题, 然后再解决问题, 是一个非常好的迭代优化方法. 对于刚刚这个问题, 可以借助 OAuth 的验证思路来解决. 调用方将请求接口的 URL 跟 AppID, 密码拼接在一起, 然后进行加密, 生成一个 token. 调用方在进行接口请求的的时候, 将这个 token 及 AppID, 随 URL 一块传递给微服务端. 微服务端接收到这些数据之后, 根据 AppID 从数据库中取出对应的密码, 并通过同样的 token 生成算法, 生成另外一个 token. 用这个新生成的 token 跟调用方传递过来的 token 对比. 如果一致, 则允许接口调用请求; 否则, 就拒绝接口调用请求.
这个方案稍微有点复杂, 我画了一张示例图, 来理解整个流程.

第三轮分析优化
不过, 这样的设计仍然存在重放攻击的风险, 还是不够安全. 每个 URL 拼接上 AppID, 密码生成的 token 都是固定的. 未认证系统截获 URL, token 和 AppID 之后, 还是可以通过重放攻击的方式, 伪装成认证系统, 调用这个 URL 对应的接口.
为了解决这个问题, 可以进一步优化 token 生成算法, 引入一个随机变量, 让每次接口请求生成的 token 都不一样. 可以选择时间戳作为随机变量. 原来的 token 是对 URL, AppID, 密码三者进行加密生成的, 现在将 URL, AppID, 密码, 时间戳四者进行加密来生成 token. 调用方在进行接口请求的时候, 将 token, AppID, 时间戳, 随 URL 一并传递给微服务端.
微服务端在收到这些数据之后, 会验证当前时间戳跟传递过来的时间戳, 是否在一定的时间窗口内(比如一分钟) . 如果超过一分钟, 则判定 token 过期, 拒绝接口请求. 如果没有超过一分钟, 则说明 token 没有过期, 就再通过同样的 token 生成算法, 在服务端生成新的 token, 与调用方传递过来的 token 比对, 看是否一致. 如果一致, 则允许接口调用请求; 否则, 就拒绝接口调用请求.
优化之后的认证流程如下图所示.

第四轮分析优化
不过, 你可能会说, 这样还是不够安全啊. 未认证系统还是可以在这一分钟的 token 失效窗口内, 通过截获请求, 重放请求, 来调用接口啊!
你说得没错. 不过, 攻与防之间, 本来就没有绝对的安全. 能做的就是尽量提高攻击的成本. 这个方案虽然还有漏洞, 但是实现起来足够简单, 而且不会过度影响接口本身的性能(比如响应时间). 所以, 权衡安全性, 开发成本, 对系统性能的影响, 这个方案算是比较折中, 比较合理的了.
实际上, 还有一个细节没有考虑到, 那就是如何在微服务端存储每个授权调用方的 AppID 和密码. 当然这个问题并不难. 最容易想到的方案就是存储到数据库里, 比如 MySQL. 不过开发像鉴权这样的非业务功能, 最好不要与具体的第三方系统有过度的耦合.
针对 AppID 和密码的存储, 最好能灵活地支持各种不同的存储方式, 比如 ZooKeeper, 本地配置文件, 自研配置中心, MySQL, Redis 等. 不一定针对每种存储方式都去做代码实现, 但起码要留有扩展点, 保证系统有足够的灵活性和扩展性, 能够在切换存储方式的时候, 尽可能地减少代码的改动.
最终确定需求
到此, 需求已经足够细化和具体了. 现在按照鉴权的流程, 对需求再重新描述一下. 如果你熟悉 UML, 也可以用时序图, 流程图来描述. 不过, 用什么描述不是重点, 描述清楚才是最重要的. 考虑到在接下来的面向对象设计环节中, 我会基于文字版本的需求描述, 来进行类, 属性, 方法, 交互等的设计, 所以, 这里给出的最终需求描述是文字版本的.
- 调用方进行接口请求的时候, 将 URL, AppID, 密码, 时间戳拼接在一起, 通过加密算法生成 token, 并且将 token, AppID, 时间戳拼接在 URL 中, 一并发送到微服务端.
- 微服务端在接收到调用方的接口请求之后, 从请求中拆解出 token, AppID, 时间戳.
- 微服务端首先检查传递过来的时间戳跟当前时间, 是否在 token 失效时间窗口内. 如果已经超过失效时间, 那就算接口调用鉴权失败, 拒绝接口调用请求.
- 如果 token 验证没有过期失效, 微服务端再从自己的存储中, 取出 AppID 对应的密码, 通过同样的 token 生成算法, 生成另外一个 token, 与调用方传递过来的 token 进行匹配; 如果一致, 则鉴权成功, 允许接口调用, 否则就拒绝接口调用.
这就是需求分析的整个思考过程, 从最粗糙, 最模糊的需求开始, 通过 "提出问题-解决问题" 的方式, 循序渐进地进行优化, 最后得到一个足够清晰, 可落地的需求描述.
重点回顾
针对框架, 类库, 组件等非业务系统的开发, 其中一个比较大的难点就是, 需求一般都比较抽象, 模糊, 需要自己去挖掘, 做合理取舍, 权衡, 假设, 把抽象的问题具象化, 最终产生清晰的, 可落地的需求定义. 需求定义是否清晰, 合理, 直接影响了后续的设计, 编码实现是否顺畅. 所以作为程序员, 一定不要只关心设计与实现, 前期的需求分析同等重要.
需求分析的过程实际上是一个不断迭代优化的过程. 不要试图一下就能给出一个完美的解决方案, 而是先给出一个粗糙的, 基础的方案, 有一个迭代的基础, 然后再慢慢优化, 这样一个思考过程能让我们摆脱无从下手的窘境.
# 14-实战二(下):如何利用面向对象设计和编程开发接口鉴权功能?
在上一节课中, 针对接口鉴权功能的开发, 讲了如何进行面向对象分析(OOA), 也就是需求分析. 实际上, 需求定义清楚之后, 这个问题就已经解决了一大半, 这也是为什么花了那么多篇幅来讲解需求分析. 下面再来看一下, 针对面向对象分析产出的需求, 如何来进行面向对象设计(OOD)和面向对象编程(OOP) .
# 1.如何进行面向对象设计?
面向对象分析的产出是详细的需求描述, 那面向对象设计的产出就是类. 在面向对象设计环节, 将需求描述转化为具体的类的设计. 把这一设计环节拆解细化一下, 主要包含以下几个部分:
- 划分职责进而识别出有哪些类;
- 定义类及其属性和方法;
- 定义类与类之间的交互关系;
- 将类组装起来并提供执行入口.
实话讲, 不管是面向对象分析还是面向对象设计, 理论的东西都不多, 所以还是结合鉴权这个例子, 在实战中体会如何做面向对象设计.
# (1)划分职责进而识别出有哪些类
在面向对象有关书籍中经常讲到, 类是现实世界中事物的一个建模. 但并不是每个需求都能映射到现实世界, 也并不是每个类都与现实世界中的事物一一对应. 对于一些抽象的概念, 是无法通过映射现实世界中的事物的方式来定义类的. 所以, 大多数讲面向对象的书籍中, 还会讲到另外一种识别类的方法, 那就是把需求描述中的名词罗列出来, 作为可能的候选类, 然后再进行筛选. 对于没有经验的初学者来说, 这个方法比较简单, 明确, 可以直接照着做.
不过, 我个人更喜欢另外一种方法, 那就是根据需求描述, 把其中涉及的功能点, 一个一个罗列出来, 然后再去看哪些功能点职责相近, 操作同样的属性, 可否应该归为同一个类. 来看一下, 针对鉴权这个例子, 具体该如何来做.
上一节已经给出了详细的需求描述, 可以再回顾一下.
首先, 要做的是逐句阅读上面的需求描述, 拆解成小的功能点, 一条一条罗列下来. 注意, 拆解出来的每个功能点要尽可能的小. 每个功能点只负责做一件很小的事情(专业叫法是"单一职责") . 下面是逐句拆解上述需求描述之后, 得到的功能点列表:
- 把 URL, AppID, 密码, 时间戳拼接为一个字符串;
- 对字符串通过加密算法加密生成 token;
- 将 token, AppID, 时间戳拼接到 URL 中, 形成新的 URL;
- 解析 URL, 得到 token, AppID, 时间戳等信息;
- 从存储中取出 AppID 和对应的密码;
- 根据时间戳判断 token 是否过期失效;
- 验证两个 token 是否匹配;
从上面的功能列表中, 可以发现, 1, 2, 6, 7 都是跟 token 有关, 负责 token 的生成, 验证; 3, 4 都是在处理 URL, 负责 URL 的拼接, 解析; 5 是操作 AppID 和密码, 负责从存储中读取 AppID 和密码. 所以, 可以粗略地得到三个核心的类: AuthToken, Url, CredentialStorage. AuthToken 负责实现 1, 2, 6, 7 这四个操作; Url 负责 3, 4 两个操作; CredentialStorage 负责 5 这个操作.
当然, 这是一个初步的类的划分, 其他一些不重要的, 边边角角的类, 可能暂时没法一下子想全, 但这也没关系, 面向对象分析, 设计, 编程本来就是一个循环迭代, 不断优化的过程. 根据需求, 先给出一个粗糙版本的设计方案, 然后基于这样一个基础, 再去迭代优化, 会更加容易一些, 思路也会更加清晰一些.
不过, 还要再强调一点, 接口调用鉴权这个开发需求比较简单, 所以需求对应的面向对象设计并不复杂, 识别出来的类也并不多. 但如果面对的是更加大型的软件开发, 更加复杂的需求开发, 涉及的功能点可能会很多, 对应的类也会比较多, 像刚刚那样根据需求逐句罗列功能点的方法, 最后会得到一个长长的列表, 就会有点凌乱, 没有规律. 针对这种复杂的需求开发, 首先要做的是进行模块划分, 将需求先简单划分成几个小的, 独立的功能模块, 然后再在模块内部, 应用刚刚讲的方法, 进行面向对象设计. 而模块的划分和识别, 跟类的划分和识别, 是类似的套路.
# (2)定义类及其属性和方法
刚刚通过分析需求描述, 识别出了三个核心的类, 它们分别是 AuthToken, Url 和 CredentialStorage. 现在来看下, 每个类都有哪些属性和方法. 还是从功能点列表中挖掘.
**AuthToken 类相关的功能点有四个: **
- 把 URL, AppID, 密码, 时间戳拼接为一个字符串;
- 对字符串通过加密算法加密生成 token;
- 根据时间戳判断 token 是否过期失效;
- 验证两个 token 是否匹配.
对于方法的识别, 很多面向对象相关的书籍, 一般都是这么讲的, 识别出需求描述中的动词, 作为候选的方法, 再进一步过滤筛选. 类比一下方法的识别, 可以把功能点中涉及的名词, 作为候选属性, 然后同样进行过滤筛选.
可以借用这个思路, 根据功能点描述, 识别出来 AuthToken 类的属性和方法, 如下所示:

从上面的类图中, 可以发现这样三个小细节.
- 并不是所有出现的名词都被定义为类的属性, 比如 URL, AppID, 密码, 时间戳这几个名词, 我们把它作为了方法的参数.
- 还需要挖掘一些没有出现在功能点描述中属性, 比如 createTime, expireTimeInterval, 它们用在 isExpired() 函数中, 用来判定 token 是否过期.
- 给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken().
第一个细节告诉我们, 从业务模型上来说, 不应该属于这个类的属性和方法, 不应该被放到这个类里. 比如 URL, AppID 这些信息, 从业务模型上来说, 不应该属于 AuthToken, 所以不应该放到这个类中.
第二, 第三个细节告诉我们, 在设计类具有哪些属性和方法的时候, 不能单纯地依赖当下的需求, 还要分析这个类从业务模型上来讲, 理应具有哪些属性和方法. 这样可以一方面保证类定义的完整性, 另一方面不仅为当下的需求还为未来的需求做些准备.
**Url 类相关的功能点有两个: **
- 将 token, AppID, 时间戳拼接到 URL 中, 形成新的 URL;
- 解析 URL, 得到 token, AppID, 时间戳等信息.
虽然需求描述中, 都是以 URL 来代指接口请求, 但是接口请求并不一定是以 URL 的形式来表达, 还有可能是 dubbo RPC 等其他形式. 为了让这个类更加通用, 命名更加贴切, 接下来把它命名为 ApiRequest. 下面是根据功能点描述设计的 ApiRequest 类.

**CredentialStorage 类相关的功能点有一个: **
- 从存储中取出 AppID 和对应的密码.
CredentialStorage 类非常简单, 类图如下所示. 为了做到抽象封装具体的存储方式, 将 CredentialStorage 设计成了接口, 基于接口而非具体的实现编程.

# (3)定义类与类之间的交互关系
类与类之间都哪些交互关系呢? UML 统一建模语言中定义了六种类之间的关系. 它们分别是: 泛化, 实现, 关联, 聚合, 组合, 依赖. 关系比较多, 而且有些还比较相近, 比如聚合和组合, 接下来就逐一讲解一下.
泛化(Generalization)可以简单理解为继承关系. 具体到 Java 代码就是下面这样:
public class A { ... }
public class B extends A { ... }
2
实现(Realization)一般是指接口和实现类之间的关系. 具体到 Java 代码就是下面这样:
public interface A {...}
public class B implements A { ... }
2
聚合(Aggregation)是一种包含关系, A 类对象包含 B 类对象, B 类对象的生命周期可以不依赖 A 类对象的生命周期, 也就是说可以单独销毁 A 类对象而不影响 B 对象, 比如课程与学生之间的关系. 具体到 Java 代码就是下面这样:
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
2
3
4
5
6
组合(Composition)也是一种包含关系. A 类对象包含 B 类对象, B 类对象的生命周期跟依赖 A 类对象的生命周期, B 类对象不可单独存在, 比如鸟与翅膀之间的关系. 具体到 Java 代码就是下面这样:
public class A {
private B b;
public A() {
this.b = new B();
}
}
2
3
4
5
6
关联(Association)是一种非常弱的关系, 包含聚合, 组合两种关系. 具体到代码层面, 如果 B 类对象是 A 类的成员变量, 那 B 类和 A 类就是关联关系. 具体到 Java 代码就是下面这样:
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
依赖(Dependency)是一种比关联关系更加弱的关系, 包含关联关系. 不管是 B 类对象是 A 类对象的成员变量, 还是 A 类的方法使用 B 类对象作为参数或者返回值, 局部变量, 只要 B 类对象和 A 类对象有任何使用关系, 都称它们有依赖关系. 具体到 Java 代码就是下面这样:
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
或者
public class A {
public void func(B b) { ... }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
看完了 UML 六种类关系的详细介绍, 不知道你有何感受? 个人觉得这样拆分有点太细, 增加了学习成本, 对于指导编程开发没有太大意义. 所以我从更加贴近编程的角度, 对类与类之间的关系做了调整, 只保留了四个关系: 泛化, 实现, 组合, 依赖, 这样你掌握起来会更加容易.
其中, 泛化, 实现, 依赖的定义不变, 组合关系替代 UML 中组合, 聚合, 关联三个概念, 也就相当于重新命名关联关系为组合关系, 并且不再区分 UML 中的组合和聚合两个概念. 之所以这样重新命名, 是为了跟前面讲的 "多用组合少用继承" 设计原则中的 "组合" 统一含义. 只要 B 类对象是 A 类对象的成员变量, 那就称 A 类跟 B 类是组合关系.
理论的东西讲完了, 来看一下, 刚刚定义的类之间都有哪些关系呢? 因为目前只有三个核心的类, 所以只用到了实现关系, 也即 CredentialStorage 和 MysqlCredentialStorage 之间的关系. 接下来讲到组装类的时候, 还会用到依赖关系, 组合关系, 但是泛化关系暂时没有用到.
# (4)将类组装起来并提供执行入口
类定义好了, 类之间必要的交互关系也设计好了, 接下来要将所有的类组装在一起, 提供一个执行入口. 这个入口可能是一个 main() 函数, 也可能是一组给外部用的 API 接口. 通过这个入口触发整个代码跑起来.
接口鉴权并不是一个独立运行的系统, 而是一个集成在系统上运行的组件, 所以, 我们封装所有的实现细节, 设计了一个最顶层的 ApiAuthencator 接口类, 暴露一组给外部调用者使用的 API 接口, 作为触发执行鉴权逻辑的入口. 具体的类的设计如下所示:

# 2.如何进行面向对象编程?
面向对象设计完成之后, 已经定义清晰了类, 属性, 方法, 类之间的交互, 并且将所有的类组装起来, 提供了统一的执行入口. 接下来, 面向对象编程的工作, 就是将这些设计思路翻译成代码实现. 有了前面的类图, 这部分工作相对来说就比较简单了. 所以, 这里只给出比较复杂的 ApiAuthencator 的实现. 对于 AuthToken, ApiRequest, CredentialStorage 这三个类, 在这里就不给出具体的代码实现了.
public interface ApiAuthencator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
public class DefaultApiAuthencatorImpl implements ApiAuthencator {
private CredentialStorage credentialStorage;
public ApiAuthencator() {
this.credentialStorage = new MysqlCredentialStorage();
}
public ApiAuthencator(CredentialStorage credentialStorage) {
this.credentialStorage = credentialStorage;
}
@Override
public void auth(String url) {
ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
auth(apiRequest);
}
@Override
public void auth(ApiRequest apiRequest) {
String appId = apiRequest.getAppId();
String token = apiRequest.getToken();
long timestamp = apiRequest.getTimestamp();
String originalUrl = apiRequest.getOriginalUrl();
AuthToken clientAuthToken = new AuthToken(token, timestamp);
if (clientAuthToken.isExpired()) {
throw new RuntimeException("Token is expired.");
}
String password = credentialStorage.getPasswordByAppId(appId);
AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
if (!serverAuthToken.match(clientAuthToken)) {
throw new RuntimeException("Token verfication failed.");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
辩证思考与灵活应用
在之前的讲解中, 面向对象分析, 设计, 实现, 每个环节的界限划分都比较清楚. 而且, 设计和实现基本上是按照功能点的描述, 逐句照着翻译过来的. 这样做的好处是先做什么, 后做什么, 非常清晰, 明确, 有章可循, 即便是没有太多设计经验的初级工程师, 都可以按部就班地参照着这个流程来做分析, 设计和实现.
不过, 在平时的工作中, 大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计, 然后就开始写代码了, 边写边思考边重构, 并不会严格地按照刚刚的流程来执行. 而且, 说实话, 即便在写代码之前, 花很多时间做分析和设计, 绘制出完美的类图, UML 图, 也不可能把每个细节, 交互都想得很清楚. 在落实到代码的时候, 还是要反复迭代, 重构, 打破重写.
毕竟, 整个软件开发本来就是一个迭代, 修修补补, 遇到问题解决问题的过程, 是一个不断重构的过程. 我们没法严格地按照顺序执行各个步骤. 这就类似你去学驾照, 驾校教的都是比较正规的流程, 先做什么, 后做什么, 只要照着做就能顺利倒车入库, 但实际上, 等你开熟练了, 倒车入库很多时候靠的都是经验和感觉.
重点回顾
面向对象分析的产出是详细的需求描述. 面向对象设计的产出是类. 在面向对象设计这一环节中, 我们将需求描述转化为具体的类的设计. 这个环节的工作可以拆分为下面四个部分.
1.划分职责进而识别出有哪些类
根据需求描述, 把其中涉及的功能点, 一个一个罗列出来, 然后再去看哪些功能点职责相近, 操作同样的属性, 可否归为同一个类.
2.定义类及其属性和方法
识别出需求描述中的动词, 作为候选的方法, 再进一步过滤筛选出真正的方法, 把功能点中涉及的名词, 作为候选属性, 然后同样再进行过滤筛选.
3.定义类与类之间的交互关系
UML 统一建模语言中定义了六种类之间的关系. 它们分别是: 泛化, 实现, 关联, 聚合, 组合, 依赖. 我们从更加贴近编程的角度, 对类与类之间的关系做了调整, 保留四个关系: 泛化, 实现, 组合, 依赖.
4.将类组装起来并提供执行入口
要将所有的类组装在一起, 提供一个执行入口. 这个入口可能是一个 main() 函数, 也可能是一组给外部用的 API 接口. 通过这个入口能触发整个代码跑起来.
# 设计原则
# 15-理论一:对于单一职责原则,如何判定某个类的职责是否够"单一"?
上几节介绍了面向对象相关的知识. 从本节开始学习一些经典的设计原则, 其中包括, SOLID, KISS, YAGNI, DRY, LOD 等.
这些设计原则, 从字面上理解, 都不难. 你一看就感觉懂了, 一看就感觉掌握了, 但真的用到项目中的时候, 你会发现, "看懂"和"会用"是两回事, 而"用好"更是难上加难. 很多同事因为对这些原则理解得不够透彻, 导致在使用的时候过于教条主义, 拿原则当真理, 生搬硬套, 适得其反.
所以, 接下来的讲解, 不仅会讲解这些原则的定义, 还会解释这些原则设计的初衷, 能解决哪些问题, 有哪些应用场景等, 让你知其然知其所以然.
前面提到了 SOLID 原则, 实际上, SOLID 原则并非单纯的 1 个原则, 而是由 5 个设计原则组成的, 它们分别是: 单一职责原则, 开闭原则, 里式替换原则, 接口隔离原则和依赖反转原则, 依次对应 SOLID 中的 S, O, L, I, D 这 5 个英文字母.
# 1.如何理解单一职责原则(SRP)?
本节要学习的是 SOLID 原则中的第一个原则: 单一职责原则.
单一职责原则的英文是 Single Responsibility Principle, 缩写为 SRP. 这个原则的英文描述是这样的: A class or module should have a single reponsibility. 如果把它翻译成中文, 那就是: 一个类或者模块只负责完成一个职责(或者功能) .
注意, 这个原则描述的对象包含两个, 一个是类(class), 一个是模块(module) . 关于这两个概念, 在专栏中, 有两种理解方式. 一种理解是: 把模块看作比类更加抽象的概念, 类也可以看作模块. 另一种理解是: 把模块看作比类更加粗粒度的代码块, 模块中包含多个类, 多个类组成一个模块.
不管哪种理解方式, 单一职责原则在应用到这两个描述对象的时候, 道理都是相通的. 为了方便理解, 接下来只从"类"设计的角度, 来讲解如何应用这个设计原则. 对于"模块"来说, 可以自行引申.
单一职责原则的定义描述非常简单, 也不难理解. 一个类只负责完成一个职责或者功能. 也就是说, 不要设计大而全的类, 要设计粒度小, 功能单一的类. 换个角度来讲就是, 一个类包含了两个或者两个以上业务不相干的功能, 那就说它职责不够单一, 应该将它拆分成多个功能更加单一, 粒度更细的类.
举个例子解释下. 比如, 一个类里既包含订单的一些操作, 又包含用户的一些操作. 而订单和用户是两个独立的业务领域模型, 将两个不相干的功能放到同一个类中, 那就违反了单一职责原则. 为了满足单一职责原则, 需要将这个类拆分成两个粒度更细, 功能更加单一的两个类: 订单类和用户类.
# 2.如何判断类的职责是否足够单一?
从刚刚这个例子来看, 单一职责原则看似不难应用. 那是因为举的这个例子比较极端, 一眼就能看出订单和用户毫不相干. 但大部分情况下, 类里的方法是归为同一类功能, 还是归为不相关的两类功能, 并不是那么容易判定的. 在真实的软件开发中, 对于一个类是否职责单一的判定, 是很难拿捏的. 举一个更加贴近实际的例子.
在一个社交产品中, 用下面的 UserInfo 类来记录用户的信息. 你觉得, UserInfo 类的设计是否满足单一职责原则呢?
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ... 省略其他属性和方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
对于这个问题, 有两种不同的观点. 一种观点是, UserInfo 类包含的都是跟用户相关的信息, 所有的属性和方法都隶属于用户这样一个业务模型, 满足单一职责原则; 另一种观点是, 地址信息在 UserInfo 类中, 所占的比重比较高, 可以继续拆分成独立的 UserAddress 类, UserInfo 只保留除 Address 之外的其他信息, 拆分之后的两个类的职责更加单一.
哪种观点更对呢? 实际上, 要从中做出选择, 不能脱离具体的应用场景. 如果在这个社交产品中, 用户的地址信息跟其他信息一样, 只是单纯地用来展示, 那 UserInfo 现在的设计就是合理的. 但如果这个社交产品发展得比较好, 之后又在产品中添加了电商的模块, 用户的地址信息还会用在电商物流中, 那最好将地址信息从 UserInfo 中拆分出来, 独立成用户物流信息(或者叫地址信息, 收货信息等).
再进一步延伸一下. 如果做这个社交产品的公司发展得越来越好, 公司内部又开发出了跟多其他产品(可以理解为其他 App). 公司希望支持统一账号系统, 也就是用户一个账号可以在公司内部的所有产品中登录. 这时候, 就需要继续对 UserInfo 进行拆分, 将跟身份认证相关的信息(比如, email, telephone 等)抽取成独立的类.
从刚刚这个例子, 可以总结出, 不同的应用场景, 不同阶段的需求背景下, 对同一个类的职责是否单一的判定, 可能都是不一样的. 在某种应用场景或者当下的需求背景下, 一个类的设计可能已经满足单一职责原则了, 但如果换个应用场景或着在未来的某个需求背景下, 可能就不满足了, 需要继续拆分成粒度更细的类.
除此之外, 从不同的业务层面去看待同一个类的设计, 对类是否职责单一, 也会有不同的认识. 比如, 例子中的 UserInfo 类. 如果从 "用户" 这个业务层面来看, UserInfo 包含的信息都属于用户, 满足职责单一原则. 如果从更加细分的 "用户展示信息", "地址信息", "登录认证信息" 等等这些更细粒度的业务层面来看, 那 UserInfo 就应该继续拆分.
综上所述, 评价一个类的职责是否足够单一, 并没有一个非常明确的, 可以量化的标准, 可以说, 这是件非常主观, 仁者见仁智者见智的事情. 实际上, 在真正的软件开发中, 也没必要过于未雨绸缪, 过度设计. 所以, 可以先写一个粗粒度的类, 满足业务需求. 随着业务的发展, 如果粗粒度的类越来越庞大, 代码越来越多, 这时就可以将这个粗粒度的类, 拆分成几个更细粒度的类. 这就是所谓的持续重构.
听到这里, 你可能会说, 这个原则如此含糊不清, 模棱两可, 到底该如何拿捏才好啊? 这里还有一些小技巧, 能够很好地帮你, 从侧面上判定一个类的职责是否够单一. 而且我个人觉得, 下面这几条判断原则, 比起很主观地去思考类是否职责单一, 要更有指导意义, 更具有可执行性:
- 类中的代码行数, 函数或属性过多, 会影响代码的可读性和可维护性, 就需要考虑对类进行拆分;
- 类依赖的其他类过多, 或者依赖类的其他类过多, 不符合高内聚, 低耦合的设计思想, 就需要考虑对类进行拆分;
- 私有方法过多, 就要考虑能否将私有方法独立到新的类中, 设置为 public 方法, 供更多的类使用, 从而提高代码的复用性;
- 比较难给类起一个合适名字, 很难用一个业务名词概括, 或者只能用一些笼统的 Manager, Context 之类的词语来命名, 这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性, 比如在 UserInfo 例子中, 如果一半的方法都是在操作 address 信息, 那就可以考虑将这几个属性和对应的方法拆分出来.
不过, 你可能还会有这样的疑问: 在上面的判定原则中, 提到类中的代码行数, 函数或者属性过多, 就有可能不满足单一职责原则. 那多少行代码才算是行数过多呢? 多少个函数, 属性才称得上过多呢? 实际上, 这个问题并不好定量地回答, 如果你是没有太多项目经验的编程初学者, 实际上, 我也可以给你一个凑活能用, 比较宽泛的, 可量化的标准, 那就是一个类的代码行数最好不能超过 200 行, 函数个数及属性个数都最好不要超过 10 个.
# 3.类的职责是否设计得越单一越好?
为了满足单一职责原则, 是不是把类拆得越细就越好呢? 答案是否定的. 还是通过一个例子来解释一下. Serialization 类实现了一个简单协议的序列化和反序列功能, 具体代码如下:
/**
* Protocol format: identifier-string;{gson string}
* For example: UEUEUE;{"a":"A","b":"B"}
*/
public class Serialization {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serialization() {
this.gson = new Gson();
}
public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
如果想让类的职责更加单一, 对 Serialization 类进一步拆分, 拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类. 拆分后的具体代码如下所示:
public class Serializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serializer() {
this.gson = new Gson();
}
public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
}
public class Deserializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Deserializer() {
this.gson = new Gson();
}
public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
虽然经过拆分之后, Serializer 类和 Deserializer 类的职责更加单一了, 但也随之带来了新的问题. 如果修改了协议的格式, 数据标识从 "UEUEUE" 改为 "DFDFDF", 或者序列化方式从 JSON 改为了 XML, 那 Serializer 类和 Deserializer 类都需要做相应的修改, 代码的内聚性显然没有原来 Serialization 高了. 而且, 如果仅仅对 Serializer 类做了协议修改, 而忘记了修改 Deserializer 类的代码, 那就会导致序列化, 反序列化不匹配, 程序运行出错, 也就是说, 拆分之后, 代码的可维护性变差了.
实际上, 不管是应用设计原则还是设计模式, 最终的目的还是提高代码的可读性, 可扩展性, 复用性, 可维护性等. 在考虑应用某一个设计原则是否合理的时候, 也可以以此作为最终的考量标准.
重点回顾
**1.如何理解单一职责原则(SRP)? **
一个类只负责完成一个职责或者功能. 不要设计大而全的类, 要设计粒度小, 功能单一的类. 单一职责原则是为了实现代码高内聚, 低耦合, 提高代码的复用性, 可读性, 可维护性.
**2.如何判断类的职责是否足够单一? **
不同的应用场景, 不同阶段的需求背景, 不同的业务层面, 对同一个类的职责是否单一, 可能会有不同的判定结果. 实际上, 一些侧面的判断指标更具有指导意义和可执行性, 比如, 出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数, 函数或者属性过多;
- 类依赖的其他类过多, 或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性.
**3.类的职责是否设计得越单一越好? **
单一职责原则通过避免设计大而全的类, 避免将不相关的功能耦合在一起, 来提高类的内聚性. 同时, 类职责单一, 类依赖的和被依赖的其他类也会变少, 减少了代码的耦合性, 以此来实现代码的高内聚, 低耦合. 但是, 如果拆分得过细, 实际上会适得其反, 反倒会降低内聚性, 也会影响代码的可维护性.
# 16-理论二:如何做到对扩展开放,修改关闭?扩展和修改各指什么?
上一节学习了单一职责原则. 今天来学习 SOLID 中的第二个原则: 开闭原则. 个人觉得, 开闭原则是 SOLID 中最难理解, 最难掌握, 同时也是最有用的一条原则.
之所以说这条原则难理解, 那是因为, "怎样的代码改动才被定义为'扩展'? 怎样的代码改动才被定义为'修改'? 怎么才算满足或违反开闭原则? 修改代码就一定意味着违反开闭原则吗? " 等等这些问题, 都比较难理解.
之所以说这条原则难掌握, 那是因为, "如何做到'对扩展开发, 修改关闭'? 如何在项目中灵活地应用开闭原则, 以避免在追求扩展性的同时影响到代码的可读性? " 等等这些问题, 都比较难掌握.
之所以说这条原则最有用, 那是因为, 扩展性是代码质量最重要的衡量标准之一. 在 23 种经典设计模式中, 大部分设计模式都是为了解决代码的扩展性问题而存在的, 主要遵从的设计原则就是开闭原则.
# 1.如何理解"对扩展开放,修改关闭"?
开闭原则的英文全称是 Open Closed Principle, 简写为 OCP. 它的英文描述是: software entities (modules, classes, functions, etc.) should be open for extension, but closed for modification. 把它翻译成中文就是: 软件实体(模块, 类, 方法等)应该"对扩展开放, 对修改关闭" .
这个描述比较简略, 如果详细表述一下就是, 添加一个新的功能应该是, 在已有代码基础上扩展代码(新增模块, 类, 方法等), 而非修改已有代码(修改模块, 类, 方法等) .
举一个例子来进一步解释一下. 这是一段 API 接口监控告警的代码.
其中, AlertRule 存储告警规则, 可以自由设置. Notification 是告警通知类, 支持邮件, 短信, 微信, 手机等多种通知渠道. NotificationEmergencyLevel 表示通知的紧急程度, 包括 SEVERE(严重), URGENCY(紧急), NORMAL(普通), TRIVIAL(无关紧要), 不同的紧急程度对应不同的发送渠道. 关于 API 接口监控告警这部分, 更加详细的业务需求分析和设计, 会在后面的设计模式模块再拿出来进一步讲解, 这里只要简单知道这些.
public class Alert {
private AlertRule rule;
private Notification notification;
public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上面这段代码非常简单, 业务逻辑主要集中在 check() 函数中. 当接口的 TPS 超过某个预先设置的最大值时, 以及当接口请求出错数大于某个最大允许值时, 就会触发告警, 通知接口的相关负责人或者团队.
现在, 如果需要添加一个功能, 当每秒钟接口超时请求个数, 超过某个预先设置的最大阈值时, 也要触发告警发送通知. 这个时候, 该如何改动代码呢? 主要的改动有两处: 第一处是修改 check() 函数的入参, 添加一个新的统计数据 timeoutCount, 表示超时接口请求数; 第二处是在 check() 函数中添加新的告警逻辑. 具体的代码改动如下所示:
public class Alert {
private AlertRule rule;
private Notification notification;
public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这样的代码修改实际上存在挺多问题的. 一方面, 对接口进行了修改, 这就意味着调用这个接口的代码都要做相应的修改. 另一方面, 修改了 check() 函数, 相应的单元测试都需要修改.
上面的代码改动是基于 "修改" 的方式来实现新功能的. 如果遵循开闭原则, 也就是"对扩展开放, 对修改关闭". 那如何通过 "扩展" 的方式, 来实现同样的功能呢?
先重构一下之前的 Alert 代码, 让它的扩展性更好一些. 重构的内容主要包含两部分:
- 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;
- 第二部分是引入 handler 的概念, 将 if 判断逻辑分散在各个 handler 中.
具体的代码实现如下所示:
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();
public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}
public void check(ApiStatInfo apiStatInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStatInfo);
}
}
}
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
public abstract class AlertHandler {
protected AlertRule rule;
protected Notification notification;
public AlertHandler(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public abstract void check(ApiStatInfo apiStatInfo);
}
public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount() / apiStatInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
上面的代码是对 Alert 的重构, 再来看下, 重构之后的 Alert 该如何使用呢? 具体的使用代码也写在这里了.
其中, ApplicationContext 是一个单例类, 负责 Alert 的创建, 组装(alertRule 和 notification 的依赖注入), 初始化(添加 handlers)工作.
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() {
return alert;
}
// 饿汉式单例
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
instance.initializeBeans();
}
public static ApplicationContext getInstance() {
return instance;
}
}
public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ... 省略设置 apiStatInfo 数据值的代码
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
现在再来看下, 基于重构之后的代码, 如果再添加上面讲到的那个新功能, 每秒钟接口超时请求个数超过某个最大阈值就告警, 又该如何改动代码呢? 主要的改动有下面四处.
- 第一处改动是: 在 ApiStatInfo 类中添加新的属性 timeoutCount.
- 第二处改动是: 添加新的 TimeoutAlertHander 类.
- 第三处改动是: 在 ApplicationContext 类的 initializeBeans() 方法中, 往 alert 对象中注册新的 timeoutAlertHandler.
- 第四处改动是: 在使用 Alert 类的时候, 需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值.
改动之后的代码如下所示:
public class Alert { // 代码未改动...
}
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
private long timeoutCount; // 改动一: 添加新字段
}
public abstract class AlertHandler { // 代码未改动...
}
public class TpsAlertHandler extends AlertHandler {// 代码未改动...
}
public class ErrorAlertHandler extends AlertHandler {// 代码未改动...
}
// 改动二: 添加新的 handler
public class TimeoutAlertHandler extends AlertHandler {// 省略代码...
}
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
// 改动三: 注册 handler
alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
}
//... 省略其他未改动代码...
}
public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ... 省略 apiStatInfo 的 set 字段代码
apiStatInfo.setTimeoutCount(289); // 改动四: 设置 tiemoutCount 值
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
重构之后的代码更加灵活和易扩展. 如果要想添加新的告警逻辑, 只需要基于扩展的方式创建新的 handler 类即可, 不需要改动原来的 check() 函数的逻辑. 而且只需要为新的 handler 类添加单元测试, 老的单元测试都不会失败, 也不用修改.
# 2.修改代码就意味着违背开闭原则吗?
看了上面重构之后的代码, 你可能还会有疑问: 在添加新的告警逻辑的时候, 尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的, 但改动一, 三, 四貌似不是基于扩展而是基于修改的方式来完成的, 那改动一, 三, 四不就违背了开闭原则吗?
**先来分析一下改动一: 往 ApiStatInfo 类中添加新的属性 timeoutCount. **
实际上, 我们不仅往 ApiStatInfo 类中添加了属性, 还添加了对应的 getter/setter 方法. 那这个问题就转化为: 给类中添加新的属性和方法, 算作"修改"还是"扩展" ?
再一块回忆一下开闭原则的定义: 软件实体(模块, 类, 方法等)应该"对扩展开放, 对修改关闭". 从定义中, 可以看出, 开闭原则可以应用在不同粒度的代码中, 可以是模块, 也可以类, 还可以是方法(及其属性). 同样一个代码改动, 在粗代码粒度下, 被认定为"修改", 在细代码粒度下, 又可以被认定为"扩展" . 比如, 改动一, 添加属性和方法相当于修改类, 在类这个层面, 这个代码改动可以被认定为"修改"; 但这个代码改动并没有修改已有的属性和方法, 在方法(及其属性)这一层面, 它又可以被认定为"扩展".
实际上, 也没必要纠结某个代码改动是"修改"还是"扩展", 更没必要太纠结它是否违反"开闭原则". 回到这条原则的设计初衷: 只要它没有破坏原有的代码的正常运行, 没有破坏原有的单元测试, 就可以说, 这是一个合格的代码改动.
**再来分析一下改动三和改动四: 在 ApplicationContext 类的 initializeBeans() 方法中, 往 alert 对象中注册新的 timeoutAlertHandler; 在使用 Alert 类的时候, 需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值. **
这两处改动都是在方法内部进行的, 不管从哪个层面(模块, 类, 方法)来讲, 都不能算是"扩展", 而是地地道道的"修改". 不过, 有些修改是在所难免的, 是可以被接受的. 为什么这么说呢? 解释一下.
在重构之后的 Alert 代码中, 核心逻辑集中在 Alert 类及其各个 handler 中, 当在添加新的告警逻辑的时候, Alert 类完全不需要修改, 而只需要扩展一个新 handler 类. 如果把 Alert 类及各个 handler 类合起来看作一个"模块", 那模块本身在添加新的功能的时候, 完全满足开闭原则. 而且要认识到, 添加一个新功能, 不可能任何模块, 类, 方法的代码都不"修改", 这个是做不到的. 类需要创建, 组装, 并且做一些初始化操作, 才能构建成可运行的的程序, 这部分代码的修改是在所难免的. 要做的是尽量让修改操作更集中, 更少, 更上层, 尽量让最核心, 最复杂的那部分逻辑代码满足开闭原则.
# 3.如何做到"对扩展开放,修改关闭"?
在刚刚的例子中, 通过引入一组 handler 的方式来实现支持开闭原则. 如果你没有太多复杂代码的设计和开发经验, 你可能会有这样的疑问: 这样的代码设计思路我怎么想不到呢? 你是怎么想到的呢?
先给你个结论, 之所以我能想到, 靠的就是理论知识和实战经验, 这些需要慢慢学习和积累. 对于如何做到"对扩展开放, 修改关闭", 也有一些指导思想和具体的方法论, 一块来看一下.
实际上, 开闭原则讲的就是代码的扩展性问题, 是判断一段代码是否易扩展的 "金标准". 如果某段代码在应对未来需求变化的时候, 能够做到"对扩展开放, 对修改关闭", 那就说明这段代码的扩展性比较好. 所以, 问如何才能做到"对扩展开放, 对修改关闭", 也就粗略地等同于在问, 如何才能写出扩展性好的代码.
**在讲具体的方法论之前, 先来看一些更加偏向顶层的指导思想. **为了尽量写出扩展性好的代码, 要时刻具备扩展意识, 抽象意识, 封装意识. 这些"潜意识"可能比任何开发技巧都重要 **. **
在写代码的时候后, 要多花点时间往前多思考一下, 这段代码未来可能有哪些需求变更, 如何设计代码结构, 事先留好扩展点, 以便在未来需求变更的时候, 不需要改动代码整体结构, 做到最小代码改动的情况下, 新的代码能够很灵活地插入到扩展点上, 做到"对扩展开放, 对修改关闭".
还有, 在识别出代码可变部分和不可变部分之后, 要将可变部分封装起来, 隔离变化, 提供抽象化的不可变接口, 给上层系统使用. 当具体的实现发生变化的时候, 只需要基于相同的抽象接口, 扩展一个新的实现, 替换掉老的实现即可, 上游系统的代码几乎不需要修改.
**刚刚讲了实现开闭原则的一些偏向顶层的指导思想, 现在再来看下, 支持开闭原则的一些更加具体的方法论. **
前面讲到, 代码的扩展性是代码质量评判的最重要的标准之一. 实际上, 整个专栏的大部分知识点都是围绕扩展性问题来讲解的. 专栏中讲到的很多设计原则, 设计思想, 设计模式, 都是以提高代码的扩展性为最终目的的. 特别是 23 种经典设计模式, 大部分都是为了解决代码的扩展性问题而总结出来的, 都是以开闭原则为指导原则的.
在众多的设计原则, 思想, 模式中, 最常用来提高代码扩展性的方法有: 多态, 依赖注入, 基于接口而非实现编程, 以及大部分的设计模式(比如, 装饰, 策略, 模板, 职责链, 状态等). 今天重点讲一下, 如何利用多态, 依赖注入, 基于接口而非实现编程, 来实现"对扩展开放, 对修改关闭".
实际上, 多态, 依赖注入, 基于接口而非实现编程, 以及前面提到的抽象意识, 说的都是同一种设计思路, 只是从不同的角度, 不同的层面来阐述而已. 这也体现了"很多设计原则, 思想, 模式都是相通的"这一思想.
接下来, 就通过一个例子来解释一下, 如何利用这几个设计思想或原则来实现"对扩展开放, 对修改关闭". 注意, 依赖注入后面会讲到, 如果你对这块不了解, 可以暂时先忽略这个概念, 只关注多态, 基于接口而非实现编程以及抽象意识.
比如, 代码中通过 Kafka 来发送异步消息. 对于这样一个功能的开发, 要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口. 所有上层系统都依赖这组抽象的接口编程, 并且通过依赖注入的方式来调用. 当要替换新的消息队列的时候, 比如将 Kafka 替换成 RocketMQ, 可以很方便地拔掉老的消息队列实现, 插入新的消息队列实现. 具体代码如下所示:
// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMQMessageQueue implements MessageQueue {//...}
public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}
public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}
// msgFormatter: 多态, 依赖注入
public void sendNotification(Notification notification, MessageFormatter msgFormatter) {
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
对于如何写出扩展性好的代码, 如何实现"对扩展开放, 对修改关闭"这个问题, 今天只是比较笼统地总结了一下, 详细的知识在后面的章节学习.
# 4.如何在项目中灵活应用开闭原则?
前面提到, 写出支持"对扩展开放, 对修改关闭"的代码的关键是预留扩展点. 那问题是如何才能识别出所有可能的扩展点呢?
如果开发的是一个业务导向的系统, 比如金融系统, 电商系统, 物流系统等, 要想识别出尽可能多的扩展点, 就要对业务有足够的了解, 能够知道当下以及未来可能要支持的业务需求. 如果开发的是跟业务无关的, 通用的, 偏底层的系统, 比如, 框架, 组件, 类库, 你需要了解 "它们会被如何使用? 今后你打算添加哪些功能? 使用者未来会有哪些更多的功能需求? "等问题.
不过, 有一句话说得好, "唯一不变的只有变化本身". 即便对业务, 对系统有足够的了解, 那也不可能识别出所有的扩展点, 即便能识别出所有的扩展点, 为这些地方都预留扩展点, 这样做的成本也是不可接受的. 没必要为一些遥远的, 不一定发生的需求去提前买单, 做过度设计.
最合理的做法是, 对于一些比较确定的, 短期内可能就会扩展, 或者需求改动对代码结构影响比较大的情况, 或者实现成本不高的扩展点, 在编写代码的时候之后, 就可以事先做些扩展性设计. 但对于一些不确定未来是否要支持的需求, 或者实现起来比较复杂的扩展点, 可以等到有需求驱动的时候, 再通过重构代码的方式来支持扩展的需求.
而且, 开闭原则也并不是免费的. 有些情况下, 代码的扩展性会跟可读性相冲突. 比如之前举的 Alert 告警的例子. 为了更好地支持扩展性, 对代码进行了重构, 重构之后的代码要比之前的代码复杂很多, 理解起来也更加有难度. 很多时候, 都需要在扩展性和可读性之间做权衡. 在某些场景下, 代码的扩展性很重要, 就可以适当地牺牲一些代码的可读性; 在另一些场景下, 代码的可读性更加重要, 那就适当地牺牲一些代码的可扩展性.
在之前举的 Alert 告警的例子中, 如果告警规则并不是很多, 也不复杂, 那 check() 函数中的 if 语句就不会很多, 代码逻辑也不复杂, 代码行数也不多, 那最初的第一种代码实现思路简单易读, 就是比较合理的选择. 相反, 如果告警规则很多, 很复杂, check() 函数的 if 语句, 代码逻辑就会很多, 很复杂, 相应的代码行数也会很多, 可读性, 可维护性就会变差, 那重构之后的第二种代码实现思路就是更加合理的选择了. 总之, 这里没有一个放之四海而皆准的参考标准, 全凭实际的应用场景来决定.
重点回顾
**1.如何理解"对扩展开放, 对修改关闭"? **
添加一个新的功能, 应该是通过在已有代码基础上扩展代码(新增模块, 类, 方法, 属性等), 而非修改已有代码(修改模块, 类, 方法, 属性等)的方式来完成. 关于定义, 有两点要注意. 第一点是, 开闭原则并不是说完全杜绝修改, 而是以最小的修改代码的代价来完成新功能的开发. 第二点是, 同样的代码改动, 在粗代码粒度下, 可能被认定为"修改"; 在细代码粒度下, 可能又被认定为"扩展".
**2.如何做到"对扩展开放, 修改关闭"? **
要时刻具备扩展意识, 抽象意识, 封装意识. 在写代码的时候, 要多花点时间思考一下, 这段代码未来可能有哪些需求变更, 如何设计代码结构, 事先留好扩展点, 以便在未来需求变更的时候, 在不改动代码整体结构, 做到最小代码改动的情况下, 将新的代码灵活地插入到扩展点上.
很多设计原则, 设计思想, 设计模式, 都是以提高代码的扩展性为最终目的的. 特别是 23 种经典设计模式, 大部分都是为了解决代码的扩展性问题而总结出来的, 都是以开闭原则为指导原则的. 最常用来提高代码扩展性的方法有: 多态, 依赖注入, 基于接口而非实现编程, 以及大部分的设计模式(比如, 装饰, 策略, 模板, 职责链, 状态).
# 17-理论三:里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?
本节再来学习 SOLID 中的 "L" 对应的原则: 里式替换原则.
整体上来讲, 这个设计原则是比较简单, 容易理解和掌握的. 这里主要通过几个反例, 来看看哪些代码是违反里式替换原则的? 该如何将它们改造成满足里式替换原则? 除此之外, 这条原则从定义上看起来, 跟之前讲过的"多态"有点类似. 所以也会讲一下, 它跟多态的区别.
# 1.如何理解"里式替换原则"?
里式替换原则的英文翻译是: Liskov Substitution Principle, 缩写为 LSP. 这个原则最早是在 1986 年由 Barbara Liskov 提出, 他是这么描述这条原则的:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.
在 1996 年, Robert Martin 在他的 SOLID 原则中, 重新描述了这个原则, 英文原话是这样的:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.
综合两者的描述, 将这条原则用中文描述出来, 是这样的: 子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方, 并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏.
这么说还是比较抽象, 通过一个例子来解释一下. 如下代码中, 父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据. 子类 SecurityTransporter 继承父类 Transporter, 增加了额外的功能, 支持传输 appId 和 appToken 安全认证信息.
public class Transporter {
private HttpClient httpClient;
public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}
public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}
public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;
public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//... 省略设置 request 中数据值的代码...
Response response = transporter.sendRequest(request);
//... 省略其他逻辑...
}
}
// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/* 省略参数 */););
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
在上面的代码中, 子类 SecurityTransporter 的设计完全符合里式替换原则, 可以替换父类出现的任何位置, 并且原来代码的逻辑行为不变且正确性也没有被破坏.
不过, 你可能会有这样的疑问, 刚刚的代码设计不就是简单利用了面向对象的多态特性吗? 多态和里式替换原则说的是不是一回事呢? 从刚刚的例子和定义描述来看, 里式替换原则跟多态看起来确实有点类似, 但实际上它们完全是两回事. 为什么这么说呢?
还是通过刚才这个例子来解释一下. 不过需要对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下. 改造前, 如果 appId 或者 appToken 没有设置, 就不做校验; 改造后, 如果 appId 或者 appToken 没有设置, 则直接抛出 NoAuthorizationRuntimeException 未授权异常. 改造前后的代码对比如下所示:
// 改造前:
public class SecurityTransporter extends Transporter {
//... 省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
// 改造后:
public class SecurityTransporter extends Transporter {
//... 省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
throw new NoAuthorizationRuntimeException(...);
}
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
return super.sendRequest(request);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在改造之后的代码中, 如果传递进 demoFunction() 函数的是父类 Transporter 对象, 那 demoFunction() 函数并不会有异常抛出, 但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象, 那 demoFunction() 有可能会有异常抛出. 尽管代码中抛出的是运行时异常(Runtime Exception), 可以不在代码中显式地捕获处理, 但子类替换父类传递进 demoFunction 函数之后, 整个程序的逻辑行为有了改变.
虽然改造之后的代码仍然可以通过 Java 的多态语法, 动态地用子类 SecurityTransporter 来替换父类 Transporter, 也并不会导致程序编译或者运行报错. 但是从设计思路上来讲, SecurityTransporter 的设计是不符合里式替换原则的.
好了, 稍微总结一下. 虽然从定义描述和代码实现上来看, 多态和里式替换有点类似, 但它们关注的角度是不一样的. 多态是面向对象编程的一大特性, 也是面向对象编程语言的一种语法, 它是一种代码实现的思路. 而里式替换是一种设计原则, 是用来指导继承关系中子类该如何设计的, 子类的设计要保证在替换父类的时候, 不改变原有程序的逻辑以及不破坏原有程序的正确性.
# 2.哪些代码明显违背了LSP?
实际上, 里式替换原则还有另外一个更加能落地, 更有指导意义的描述, 那就是 "Design By Contract", 中文翻译就是 "按照协议来设计".
看起来比较抽象, 来进一步解读一下. 子类在设计的时候, 要遵守父类的行为约定(或者叫协议). 父类定义了函数的行为约定, 那子类可以改变函数的内部实现逻辑, 但不能改变函数原有的行为约定. 这里的行为约定包括: 函数声明要实现的功能; 对输入, 输出, 异常的约定; 甚至包括注释中所罗列的任何特殊说明. 实际上, 定义中父类和子类之间的关系, 也可以替换成接口和实现类之间的关系.
为了更好地理解这句话, 举几个违反里式替换原则的例子来解释一下.
子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数, 是按照金额从小到大来给订单排序的, 而子类重写这个 sortOrdersByAmount() 订单排序函数之后, 是按照创建日期来给订单排序的. 那子类的设计就违背里式替换原则.
子类违背父类对输入, 输出, 异常的约定
在父类中, 某个函数约定: 运行出错的时候返回 null; 获取数据为空的时候返回空集合(empty collection). 而子类重载函数之后, 实现变了, 运行出错返回异常(exception), 获取不到数据返回 null. 那子类的设计就违背里式替换原则.
在父类中, 某个函数约定, 输入数据可以是任意整数, 但子类实现的时候, 只允许输入数据是正整数, 负数就抛出, 也就是说, 子类对输入的数据的校验比父类更加严格, 那子类的设计就违背了里式替换原则.
在父类中, 某个函数约定, 只会抛出 ArgumentNullException 异常, 那子类的设计实现中只允许抛出 ArgumentNullException 异常, 任何其他异常的抛出, 都会导致子类违背里式替换原则.
子类违背父类注释中所罗列的任何特殊说明
父类中定义的 withdraw() 提现函数的注释是这么写的: "用户的提现金额不得超过账户余额...", 而子类重写 withdraw() 函数之后, 针对 VIP 账号实现了透支提现的功能, 也就是提现金额可以大于账户余额, 那这个子类的设计也是不符合里式替换原则的.
以上便是三种典型的违背里式替换原则的情况. 除此之外, 判断子类的设计实现是否违背里式替换原则, 还有一个小窍门, 那就是拿父类的单元测试去验证子类的代码. 如果某些单元测试运行失败, 就有可能说明, 子类的设计实现没有完全地遵守父类的约定, 子类有可能违背了里式替换原则.
实际上, 你有没有发现, 里式替换这个原则是非常宽松的. 一般情况下, 我们写的代码都不怎么会违背它.
重点回顾
里式替换原则是用来指导, 继承关系中子类该如何设计的一个原则. 理解里式替换原则, 最核心的就是理解 "design by contract, 按照协议来设计" 这几个字. 父类定义了函数的"约定"(或者叫协议), 那子类可以改变函数的内部实现逻辑, 但不能改变函数原有的"约定". 这里的约定包括: 函数声明要实现的功能; 对输入, 输出, 异常的约定; 甚至包括注释中所罗列的任何特殊说明.
理解这个原则, 还要弄明白里式替换原则跟多态的区别. 虽然从定义描述和代码实现上来看, 多态和里式替换有点类似, 但它们关注的角度是不一样的. 多态是面向对象编程的一大特性, 也是面向对象编程语言的一种语法. 它是一种代码实现的思路. 而里式替换是一种设计原则, 用来指导继承关系中子类该如何设计, 子类的设计要保证在替换父类的时候, 不改变原有程序的逻辑及不破坏原有程序的正确性.
# 18-理论四:接口隔离原则有哪三种应用?原则中的"接口"该如何理解?
今天学习第四个原则, 接口隔离原则. 它对应 SOLID 中的英文字母"I".
对于这个原则, 最关键就是理解其中"接口"的含义. 那针对"接口", 不同的理解方式, 对应在原则上也有不同的解读方式. 除此之外, 接口隔离原则跟之前讲到的单一职责原则还有点类似, 所以今天也会具体讲一下它们之间的区别和联系.
# 1.如何理解"接口隔离原则"?
接口隔离原则的英文翻译是" Interface Segregation Principle", 缩写为 ISP. Robert Martin 在 SOLID 原则中是这样定义它的: "Clients should not be forced to depend upon interfaces that they do not use.". 直译成中文的话就是: 客户端不应该强迫依赖它不需要的接口. 其中的"客户端", 可以理解为接口的调用者或者使用者.
实际上, "接口"这个名词可以用在很多场合中. 生活中可以用它来指插座接口等. 在软件开发中, 既可以把它看作一组抽象的约定, 也可以具体指系统与系统之间的 API 接口, 还可以特指面向对象编程语言中的接口等.
前面提到, 理解接口隔离原则的关键, 就是理解其中的"接口"二字. 在这条原则中, 可以把"接口"理解为下面三种东西:
- 一组 API 接口集合
- 单个 API 接口或函数
- OOP 中的接口概念
接下来就按照这三种理解方式来详细讲一下, 在不同的场景下, 这条原则具体是如何解读和应用的.
# 2.把"接口"理解为一组API接口集合
还是结合一个例子来讲解. 微服务用户系统提供了一组跟用户相关的 API 给其他系统使用, 比如: 注册, 登录, 获取用户信息等. 具体代码如下所示:
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public class UserServiceImpl implements UserService {
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现在, 后台管理系统要实现删除用户的功能, 希望用户系统提供一个删除用户的接口. 这个时候该如何来做呢? 你可能会说, 这不是很简单吗, 只需要在 UserService 中新添加一个 deleteUserByCellphone() 或 deleteUserById() 接口就可以了. 这个方法可以解决问题, 但是也隐藏了一些安全隐患.
删除用户是一个非常慎重的操作, 我们只希望通过后台管理系统来执行, 所以这个接口只限于给后台管理系统使用. 如果把它放到 UserService 中, 那所有使用到 UserService 的系统, 都可以调用这个接口. 不加限制地被其他业务系统调用, 就有可能导致误删用户.
当然, 最好的解决方案是从架构设计的层面, 通过接口鉴权的方式来限制接口的调用. 不过, 如果暂时没有鉴权框架来支持, 还可以从代码设计的层面, 尽量避免接口被误用. 参照接口隔离原则, 调用者不应该强迫依赖它不需要的接口, 将删除接口单独放到另外一个接口 RestrictedUserService 中, 然后将 RestrictedUserService 只打包提供给后台管理系统来使用. 具体的代码实现如下所示:
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public interface RestrictedUserService {
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}
public class UserServiceImpl implements UserService, RestrictedUserService {
// ... 省略实现代码...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在这个例子中, 把接口隔离原则中的接口, 理解为一组接口集合, 它可以是某个微服务的接口, 也可以是某个类库的接口等等. 在设计微服务或者类库接口的时候, 如果部分接口只被部分调用者使用, 那就需要将这部分接口隔离出来, 单独给对应的调用者使用, 而不是强迫其他调用者也依赖这部分不会被用到的接口.
# 3.把"接口"理解为单个API接口或函数
现在再换一种理解方式, 把接口理解为单个接口或函数(以下为了方便讲解, 都简称为"函数"). 那接口隔离原则就可以理解为: 函数的设计要功能单一, 不要将多个不同的功能逻辑在一个函数中实现. 接下来, 还是通过一个例子来解释一下.
public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//... 省略 constructor/getter/setter 等方法...
}
public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//... 省略计算逻辑...
return statistics;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在上面的代码中, count() 函数的功能不够单一, 包含很多不同的统计功能, 比如求最大值, 最小值, 平均值等等. 按照接口隔离原则, 应该把 count() 函数拆成几个更小粒度的函数, 每个函数负责一个独立的统计功能. 拆分之后的代码如下所示:
public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ... 省略其他统计函数...
2
3
4
不过, 你可能会说, 在某种意义上讲, count() 函数也不能算是职责不够单一, 毕竟它做的事情只跟统计相关. 在讲单一职责原则的时候, 也提到过类似的问题. 实际上, 判定功能是否单一, 除了很强的主观性, 还需要结合具体的场景.
如果在项目中, 对每个统计需求, Statistics 定义的那几个统计信息都有涉及, 那 count() 函数的设计就是合理的. 相反, 如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分, 比如有的只需要用到 max, min, average 这三类统计信息, 有的只需要用到 average, sum. 而 count() 函数每次都会把所有的统计信息计算一遍, 就会做很多无用功, 势必影响代码的性能, 特别是在需要统计的数据量很大的时候. 所以在这个应用场景下, count() 函数的设计就有点不合理了, 应该按照第二种设计思路, 将其拆分成粒度更细的多个统计函数.
不过, 你应该已经发现, 接口隔离原则跟单一职责原则有点类似, 不过稍微还是有点区别. 单一职责原则针对的是模块, 类, 接口的设计. 而接口隔离原则相对于单一职责原则, 一方面它更侧重于接口的设计, 另一方面它的思考的角度不同. 它提供了一种判断接口是否职责单一的标准: 通过调用者如何使用接口来间接地判定. 如果调用者只使用部分接口或接口的部分功能, 那接口的设计就不够职责单一.
# 4.把"接口"理解为OOP中的接口概念
除了刚讲过的两种理解方式, 还可以把"接口"理解为 OOP 中的接口概念, 比如 Java 中的 interface. 还是通过一个例子来解释.
假设项目中用到了三个外部系统: Redis, MySQL, Kafka. 每个系统都对应一系列配置信息, 比如地址, 端口, 访问超时时间等. 为了在内存中存储这些配置信息, 供项目中的其他模块来使用, 分别设计实现了三个 Configuration 类: RedisConfig, MysqlConfig, KafkaConfig. 具体的代码实现如下所示. 注意, 这里只给出了 RedisConfig 的代码实现, 另外两个都是类似的.
public class RedisConfig {
private ConfigSource configSource; // 配置中心(比如 zookeeper)
private String address;
private int timeout;
private int maxTotal;
// 省略其他配置: maxWaitMillis,maxIdle,minIdle...
public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}
public String getAddress() {
return this.address;
}
//... 省略其他 get(), init() 方法...
public void update() {
// 从 configSource 加载配置到 address/timeout/maxTotal...
}
}
public class KafkaConfig { //... 省略...
}
public class MysqlConfig { //... 省略...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
现在有一个新的功能需求, 希望支持 Redis 和 Kafka 配置信息的热更新. 所谓"热更新(hot update)"就是, 如果在配置中心中更改了配置信息, 希望在不用重启系统的情况下, 能将最新的配置信息加载到内存中(也就是 RedisConfig, KafkaConfig 类中) . 但是, 因为某些原因, 并不希望对 MySQL 的配置信息进行热更新.
为了实现这样一个功能需求, 我们设计实现了一个 ScheduledUpdater 类, 以固定时间频率(periodInSeconds)来调用 RedisConfig, KafkaConfig 的 update() 方法更新配置信息. 具体的代码实现如下所示:
public interface Updater {
void update();
}
public class RedisConfig implemets Updater {
//... 省略其他属性和方法...
@Override
public void update() { //...
}
}
public class KafkaConfig implements Updater {
//... 省略其他属性和方法...
@Override
public void update() { //...
}
}
public class MysqlConfig { //... 省略其他属性和方法...
}
public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;
public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}
public void run() {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource(/* 省略参数 */);
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
刚刚的热更新的需求已经搞定了. 现在又有了一个新的监控功能需求. 通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的. 所以希望能有一种更加方便的配置信息查看方式.
可以在项目中开发一个内嵌的 SimpleHttpServer, 输出项目的配置信息到一个固定的 HTTP 地址, 比如: http://127.0.0.1:2389/config. 只需要在浏览器中输入这个地址, 就可以显示出系统的配置信息. 不过, 出于某些原因, 只想暴露 MySQL 和 Redis 的配置信息, 不想暴露 Kafka 的配置信息.
为了实现这样一个功能, 还需要对上面的代码做进一步改造. 改造之后的代码如下所示:
public interface Updater {
void update();
}
public interface Viewer {
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implemets Updater,Viewer {
//... 省略其他属性和方法...
@Override
public void update() { //...
}
@Override
public String outputInPlainText() { //...
}
@Override
public Map<String, String> output() { //...
}
}
public class KafkaConfig implements Updater {
//... 省略其他属性和方法...
@Override
public void update() { //...
}
}
public class MysqlConfig implements Viewer {
//... 省略其他属性和方法...
@Override
public String outputInPlainText() { //...
}
@Override
public Map<String, String> output() { //...
}
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Viewer>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...
}
public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Viewer>());
}
this.viewers.get(urlDirectory).add(viewer);
}
public void run() { //...
}
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“ 127.0 .0 .1”,2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
至此, 热更新和监控的需求就都实现了. 来回顾一下这个例子的设计思想.
我们设计了两个功能非常单一的接口: Updater 和 Viewer. ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口, 不需要被强迫去依赖不需要的 Viewer 接口, 满足接口隔离原则. 同理, SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口, 不依赖不需要的 Updater 接口, 也满足接口隔离原则.
你可能会说, 如果不遵守接口隔离原则, 不设计 Updater 和 Viewer 两个小接口, 而是设计一个大而全的 Config 接口, 让 RedisConfig, KafkaConfig, MysqlConfig 都实现这个 Config 接口, 并且将原来传递给 ScheduledUpdater 的 Updater 和传递给 SimpleHttpServer 的 Viewer, 都替换为 Config, 那会有什么问题呢? 先来看一下, 按照这个思路来实现的代码是什么样的.
// 合并为一个接口
public interface Config {
void update();
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class KafkaConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class MysqlConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class ScheduledUpdater {
//... 省略其他属性和方法..
private Config config;
public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
this.config = config;
//...
}
//...
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Config>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...
}
public void addViewer(String urlDirectory, Config config) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Config>());
}
viewers.get(urlDirectory).add(config);
}
public void run() { //...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
这样的设计思路也是能工作的, 但是对比前后两个设计思路, 在同样的代码量, 实现复杂度, 同等可读性的情况下, 第一种设计思路显然要比第二种好很多. 为什么这么说呢? 主要有两点原因.
**首先, 第一种设计思路更加灵活, 易扩展, 易复用. ** 因为 Updater, Viewer 职责更加单一, 单一就意味了通用, 复用性好. 比如现在又有一个新的需求, 开发一个 Metrics 性能统计模块, 并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上, 以方便查看. 这个时候, 尽管 Metrics 跟 RedisConfig 等没有任何关系, 但仍然可以让 Metrics 类实现非常通用的 Viewer 接口, 复用 SimpleHttpServer 的代码实现. 具体的代码如下所示:
public class ApiMetrics implements Viewer {//...}
public class DbMetrics implements Viewer {//...}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource);
public static final ApiMetrics apiMetrics = new ApiMetrics();
public static final DbMetrics dbMetrics = new DbMetrics();
public static void main(String[] args) {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mySqlConfig);
simpleHttpServer.addViewer("/metrics", apiMetrics);
simpleHttpServer.addViewer("/metrics", dbMetrics);
simpleHttpServer.run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
**其次, 第二种设计思路在代码实现上做了一些无用功. ** 因为 Config 接口中包含两类不相关的接口, 一类是 update(), 一类是 output() 和 outputInPlainText(). 理论上, KafkaConfig 只需要实现 update() 接口, 并不需要实现 output() 相关的接口. 同理, MysqlConfig 只需要实现 output() 相关接口, 并需要实现 update() 接口. 但第二种设计思路要求 RedisConfig, KafkaConfig, MySqlConfig 必须同时实现 Config 的所有接口函数(update, output, outputInPlainText). 除此之外, 如果要往 Config 中继续添加一个新的接口, 那所有的实现类都要改动. 相反, 如果接口粒度比较小, 那涉及改动的类就比较少.
重点回顾
**1.如何理解"接口隔离原则"? **
理解"接口隔离原则"的重点是理解其中的"接口"二字. 这里有三种不同的理解.
如果把"接口"理解为一组接口集合, 可以是某个微服务的接口, 也可以是某个类库的接口等. 如果部分接口只被部分调用者使用, 我们就需要将这部分接口隔离出来, 单独给这部分调用者使用, 而不强迫其他调用者也依赖这部分不会被用到的接口.
如果把"接口"理解为单个 API 接口或函数, 部分调用者只需要函数中的部分功能, 那我们就需要把函数拆分成粒度更细的多个函数, 让调用者只依赖它需要的那个细粒度函数.
如果把"接口"理解为 OOP 中的接口, 也可以理解为面向对象编程语言中的接口语法. 那接口的设计要尽量单一, 不要让接口的实现类和调用者, 依赖不需要的接口函数.
2.接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块, 类, 接口的设计. 接口隔离原则相对于单一职责原则, 一方面更侧重于接口的设计, 另一方面它的思考角度也是不同的. 接口隔离原则提供了一种判断接口的职责是否单一的标准: 通过调用者如何使用接口来间接地判定. 如果调用者只使用部分接口或接口的部分功能, 那接口的设计就不够职责单一.
# 19-理论五:控制反转,依赖反转,依赖注入,这三者有何区别和联系?
本节再来学习最后一个原则: 依赖反转原则.
前面几节讲到, 单一职责原则和开闭原则的原理比较简单, 但是想要在实践中用好却比较难. 而今天要讲到的依赖反转原则正好相反. 这个原则用起来比较简单, 但概念理解起来比较难. 比如, 下面这几个问题, 你看看能否清晰地回答出来:
- "依赖反转"这个概念指的是 "谁跟谁" 的 "什么依赖" 被反转了? "反转" 两个字该如何理解?
- 我们还经常听到另外两个概念: "控制反转"和"依赖注入". 这两个概念跟"依赖反转"有什么区别和联系呢? 它们说的是同一个事情吗?
- 如果你熟悉 Java 语言, 那 Spring 框架中的 IOC 跟这些概念又有什么关系呢?
# 1.控制反转(IOC)
在讲 "依赖反转原则" 之前, 先讲一讲"控制反转". 控制反转的英文翻译是 Inversion Of Control, 缩写为 IOC. 强调一下, 如果你是 Java 工程师的话, 暂时别把这个 "IOC" 跟 Spring 框架的 IOC 联系在一起. 关于 Spring 的 IOC, 待会儿还会讲到.
先通过一个例子来看一下, 什么是控制反转.
public class UserServiceTest {
public static boolean doTest() {
// ...
}
public static void main(String[] args) {// 这部分逻辑可以放到框架中
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的代码中, 所有的流程都由程序员来控制. 如果抽象出一个下面这样一个框架, 再来看, 如何利用框架来实现同样的功能. 具体的代码实现如下所示:
public abstract class TestCase {
public void run() {
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
public abstract void doTest();
}
public class JunitApplication {
private static final List<TestCase> testCases = new ArrayList<>();
public static void register(TestCase testCase) {
testCases.add(testCase);
}
public static final void main(String[] args) {
for (TestCase case : testCases){
case.run();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
把这个简化版本的测试框架引入到工程中之后, 只需要在框架预留的扩展点, 也就是 TestCase 类中的 doTest() 抽象函数中, 填充具体的测试代码就可以实现之前的功能了, 完全不需要写负责执行流程的 main() 函数了. 具体的代码如下所示:
public class UserServiceTest extends TestCase {
@Override
public boolean doTest() {
// ...
}
}
// 注册操作还可以通过配置的方式来实现, 不需要程序员显示调用 register()
JunitApplication.register(new UserServiceTest());
2
3
4
5
6
7
8
9
刚刚举的这个例子, 就是典型的通过框架来实现 "控制反转" 的例子. 框架提供了一个可扩展的代码骨架, 用来组装对象, 管理整个执行流程. 程序员利用框架进行开发的时候, 只需要往预留的扩展点上, 添加跟自己业务相关的代码, 就可以利用框架来驱动整个程序流程的执行.
这里的 "控制" 指的是对程序执行流程的控制, 而 "反转" 指的是在没有使用框架之前, 程序员自己控制整个程序的执行. 在使用框架之后, 整个程序的执行流程可以通过框架来控制. 流程的控制权从程序员 "反转" 到了框架.
实际上, 实现控制反转的方法有很多, 除了刚才例子中所示的类似于模板设计模式的方法之外, 还有马上要讲到的依赖注入等方法, 所以, 控制反转并不是一种具体的实现技巧, 而是一个比较笼统的设计思想, 一般用来指导框架层面的设计.
# 2.依赖注入(DI)
接下来再来看依赖注入. 依赖注入跟控制反转恰恰相反, 它是一种具体的编码技巧. 依赖注入的英文翻译是 Dependency Injection, 缩写为 DI. 对于这个概念, 有一个非常形象的说法, 那就是: 依赖注入是一个标价 25 美元, 实际上只值 5 美分的概念. 也就是说, 这个概念听起来很"高大上", 实际上理解应用起来非常简单.
那到底什么是依赖注入呢? 用一句话来概括就是: 不通过 new() 的方式在类内部创建依赖类对象, 而是将依赖的类对象在外部创建好之后, 通过构造函数, 函数参数等方式传递(或注入)给类使用.
还是通过一个例子来解释一下. 在这个例子中, Notification 类负责消息推送, 依赖 MessageSender 类实现推送商品促销, 验证码等消息给用户. 分别用依赖注入和非依赖注入两种方式来实现一下.
非依赖注入实现方式:
public class Notification {
private MessageSender messageSender;
public Notification() {
this.messageSender = new MessageSender(); // 此处有点像 hardcode
}
public void sendMessage(String cellphone, String message) {
//... 省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
public class MessageSender {
public void send(String cellphone, String message) {
//....
}
}
// 使用 Notification
Notification notification = new Notification();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
依赖注入的实现方式:
public class Notification {
private MessageSender messageSender;
// 通过构造函数将 messageSender 传递进来
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
//... 省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
// 使用 Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
通过依赖注入的方式来将依赖的类对象传递进来, 这样就提高了代码的扩展性, 可以灵活地替换依赖的类. 这一点在之前讲"开闭原则"的时候也提到过. 当然, 上面代码还有继续优化的空间, 还可以把 MessageSender 定义成接口, 基于接口而非实现编程. 改造后的代码如下所示:
public class Notification {
private MessageSender messageSender;
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
this.messageSender.send(cellphone, message);
}
}
public interface MessageSender {
void send(String cellphone, String message);
}
// 短信发送类
public class SmsSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
// 站内信发送类
public class InboxSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
// 使用 Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
实际上, 你只需要掌握刚刚举的这个例子, 就等于完全掌握了依赖注入. 尽管依赖注入非常简单, 但却非常有用, 在后面的章节中会讲到它是编写可测试性代码最有效的手段.
# 3.依赖注入框架(DI Framework)
弄懂了什么是"依赖注入", 再来看一下, 什么是"依赖注入框架". 还是借用刚刚的例子来解释.
在采用依赖注入实现的 Notification 类中, 虽然不需要用类似 hard code 的方式, 在类内部通过 new 来创建 MessageSender 对象, 但是这个创建对象, 组装(或注入)对象的工作仅仅是被移动到了更上层代码而已, 还是需要程序员自己来实现. 具体代码如下所示:
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); // 创建对象
Notification notification = new Notification(sender);// 依赖注入
notification.sendMessage("13918942177", "短信验证码: 2346");
}
}
2
3
4
5
6
7
在实际的软件开发中, 一些项目可能会涉及几十, 上百, 甚至几百个类, 类对象的创建和依赖注入会变得非常复杂. 如果这部分工作都是靠程序员自己写代码来完成, 容易出错且开发成本也比较高. 而对象创建和依赖注入的工作, 本身跟具体的业务无关, 完全可以抽象成框架来自动完成. 这个框架就是"依赖注入框架" . 我们只需要通过依赖注入框架提供的扩展点(比如 Spring 中的 @Component 注解表示自动由框架创建对象), 简单配置一下所有需要创建的类对象, 类与类之间的依赖关系, 就可以实现由框架来自动创建对象, 管理对象的生命周期, 依赖注入等原本需要程序员来做的事情.
实际上, 现成的依赖注入框架有很多, 比如 Google Guice, Java Spring, Pico Container, Butterfly Container 等. 不过, 如果你熟悉 Java Spring 框架, 你可能会说, Spring 框架自己声称是控制反转容器(Inversion Of Control Container).
实际上, 这两种说法都没错. 只是控制反转容器这种表述是一种非常宽泛的描述, DI 依赖注入框架的表述更具体, 更有针对性. 因为前面讲到实现控制反转的方式有很多, 除了依赖注入, 还有模板模式等, 而 Spring 框架的控制反转主要是通过依赖注入来实现的. 不过这点区分并不是很明显, 也不是很重要, 稍微了解一下就可以了.
# 4.依赖反转原则(DIP)
前面讲了控制反转, 依赖注入, 依赖注入框架, 现在来讲一讲今天的主角: 依赖反转原则. 依赖反转原则的英文翻译是 Dependency Inversion Principle, 缩写为 DIP. 中文翻译有时候也叫依赖倒置原则.
为了追本溯源, 我先给出这条原则的英文描述:
High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
将它翻译成中文, 大概意思就是: 高层模块(high-level modules)不要依赖低层模块(low-level). 高层模块和低层模块应该通过抽象(abstractions)来互相依赖. 除此之外, 抽象(abstractions)不要依赖具体实现细节(details), 具体实现细节(details)依赖抽象(abstractions) .
所谓高层模块和低层模块的划分, 简单来说就是, 在调用链上, 调用者属于高层, 被调用者属于低层. 在平时的业务代码开发中, 高层模块依赖底层模块是没有任何问题的. 实际上, 这条原则主要还是用来指导框架层面的设计, 跟前面讲到的控制反转类似.
拿 Tomcat 这个 Servlet 容器作为例子来解释一下. Tomcat 是运行 Java Web 应用程序的容器. Web 应用程序代码只需要部署在 Tomcat 容器下, 便可以被 Tomcat 容器调用执行. 按照之前的划分原则, Tomcat 就是高层模块, 编写的 Web 应用程序代码就是低层模块. Tomcat 和应用程序代码之间并没有直接的依赖关系, 两者都依赖同一个"抽象", 也就是 Sevlet 规范. Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节, 而 Tomcat 容器和应用程序依赖 Servlet 规范.
重点回顾
1.控制反转
实际上, 控制反转是一个比较笼统的设计思想, 并不是一种具体的实现方法, 一般用来指导框架层面的设计. 这里所说的"控制"指的是对程序执行流程的控制, 而"反转"指的是在没有使用框架之前, 程序员自己控制整个程序的执行. 在使用框架之后, 整个程序的执行流程通过框架来控制. 流程的控制权从程序员"反转"给了框架.
2.依赖注入
依赖注入和控制反转恰恰相反, 它是一种具体的编码技巧. 不通过 new 的方式在类内部创建依赖类的对象, 而是将依赖的类对象在外部创建好之后, 通过构造函数, 函数参数等方式传递(或注入)给类来使用.
3.依赖注入框架
通过依赖注入框架提供的扩展点, 简单配置一下所有需要的类及其类与类之间依赖关系, 就可以实现由框架来自动创建对象, 管理对象的生命周期, 依赖注入等原本需要程序员来做的事情.
4.依赖反转原则
依赖反转原则也叫作依赖倒置原则. 这条原则跟控制反转有点类似, 主要用来指导框架层面的设计. 高层模块不依赖低层模块, 它们共同依赖同一个抽象. 抽象不要依赖具体实现细节, 具体实现细节依赖抽象.
# 20-理论六:为何说KISS,YAGNI原则看似简单,却经常被用错?
上几节学习了经典的 SOLID 原则. 本节讲两个设计原则: KISS 原则和 YAGNI 原则.
理解这两个原则时候, 经常会有一个共同的问题, 那就是, 看一眼就感觉懂了, 但深究的话, 又有很多细节问题不是很清楚. 比如, 怎么理解 KISS 原则中 "简单" 两个字? 什么样的代码才算" 简单"? 怎样的代码才算 "复杂"? 如何才能写出"简单"的代码? YAGNI 原则跟 KISS 原则说的是一回事吗?
# 1.如何理解"KISS原则"?
KISS 原则的英文描述有好几个版本, 比如下面这几个.
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
不过, 仔细看你就会发现, 它们要表达的意思其实差不多, 翻译成中文就是: 尽量保持简单.
KISS 原则算是一个万金油类型的设计原则, 可以应用在很多场景中. 它不仅经常用来指导软件开发, 还经常用来指导更加广泛的系统设计, 产品设计等, 比如, 冰箱, 建筑, iPhone 手机的设计等等. 不过, 咱们的专栏是讲代码设计的, 所以还是重点讲解如何在编码开发中应用这条原则.
代码的可读性和可维护性是衡量代码质量非常重要的两个标准. 而 KISS 原则就是保持代码可读和可维护的重要手段. 代码足够简单, 也就意味着很容易读懂, bug 比较难隐藏. 即便出现 bug, 修复起来也比较简单.
不过, 这条原则只是告诉我们, 要保持代码 "Simple and Stupid", 但并没有讲到, 什么样的代码才是 "Simple and Stupid" 的, 更没有给出特别明确的方法论, 来指导如何开发出 "Simple and Stupid" 的代码. 所以, 看着非常简单, 但不能落地.
为了能让这条原则切实地落地, 能够指导实际的项目开发, 我就针对刚刚的这些问题来进一步讲讲我的理解.
# 2.代码行数越少就越"简单"吗?
先一起看一个例子. 下面这三段代码可以实现同样一个功能: 检查输入的字符串 ipAddress 是否是合法的 IP 地址.
一个合法的 IP 地址由四个数字组成, 并且通过 "." 来进行分割. 每组数字的取值范围是 0~255. 第一组数字比较特殊, 不允许为 0. 对比这三段代码, 你觉得哪一段代码最符合 KISS 原则呢? 如果让你来实现这个功能, 你会选择用哪种实现方法呢?
// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i <mark> 0 && ipUnitIntValue </mark> 0) {
return false;
}
}
return true;
}
// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c <mark> '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue </mark> 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
第一种实现方式利用的是正则表达式, 只用三行代码就把这个问题搞定了. 它的代码行数最少, 那是不是就最符合 KISS 原则呢? 答案是否定的. 虽然代码行数最少, 看似最简单, 实际上却很复杂. 这正是因为它使用了正则表达式. 一方面, 正则表达式本身是比较复杂的, 写出完全没有 bug 的正则表达本身就比较有挑战; 另一方面, 并不是每个程序员都精通正则表达式. 对于不怎么懂正则表达式的同事来说, 看懂并且维护这段正则表达式是比较困难的. 这种实现方式会导致代码的可读性和可维护性变差, 所以, 从 KISS 原则的设计初衷上来讲, 这种实现方式并不符合 KISS 原则.
第二种实现方式使用了 StringUtils 类, Integer 类提供的一些现成的工具函数, 来处理 IP 地址字符串. 第三种实现方式, 不使用任何工具函数, 而是通过逐一处理 IP 地址中的字符, 来判断是否合法. 从代码行数上来说, 这两种方式差不多. 但是, 第三种要比第二种更加有难度, 更容易写出 bug. 从可读性上来说, 第二种实现方式的代码逻辑更清晰, 更好理解. 所以, 在这两种实现方式中, 第二种实现方式更加"简单", 更加符合 KISS 原则.
不过, 你可能会说, 第三种实现方式虽然实现起来稍微有点复杂, 但性能要比第二种实现方式高一些啊. 从性能的角度来说, 选择第三种实现方式是不是更好些呢? 先解释一下, 为什么说第三种实现方式性能会更高一些. 一般来说, 工具类的功能都比较通用和全面, 所以在代码实现上, 需要考虑和处理更多的细节, 执行效率就会有所影响. 而第三种实现方式, 完全是自己操作底层字符, 只针对 IP 地址这一种格式的数据输入来做处理, 没有太多多余的函数调用和其他不必要的处理逻辑, 所以在执行效率上, 这种类似定制化的处理代码方式肯定比通用的工具类要高些.
不过, 尽管第三种实现方式性能更高些, 但我还是更倾向于选择第二种实现方法. 那是因为第三种实现方式实际上是一种过度优化. 除非 isValidIpAddress() 函数是影响系统性能的瓶颈代码, 否则, 这样优化的投入产出比并不高, 增加了代码实现的难度, 牺牲了代码的可读性, 性能上的提升却并不明显.
# 3.代码逻辑复杂就违背KISS原则吗?
刚刚提到, 并不是代码行数越少就越"简单", 还要考虑逻辑复杂度, 实现难度, 代码的可读性等. 那如果一段代码的逻辑复杂, 实现难度大, 可读性也不太好, 是不是就一定违背 KISS 原则呢? 在回答这个问题之前, 先来看下面这段代码:
// KMP algorithm: a, b 分别是主串和模式串; n, m 分别是主串和模式串的长度.
public static int kmp(char[] a, int n, char[] b, int m) {
int[] next = getNexts(b, m);
int j = 0;
for (int i = 0; i < n; ++i) {
while (j > 0 && a[i] != b[j]) { // 一直找到 a[i] 和 b[j]
j = next[j - 1] + 1;
}
if (a[i] <mark> b[j]) {
++j;
}
if (j </mark> m) { // 找到匹配模式串的了
return i - m + 1;
}
}
return -1;
}
// b 表示模式串, m 表示模式串的长度
private static int[] getNexts(char[] b, int m) {
int[] next = new int[m];
next[0] = -1;
int k = -1;
for (int i = 1; i < m; ++i) {
while (k != -1 && b[k + 1] != b[i]) {
k = next[k];
}
if (b[k + 1] == b[i]) {
++k;
}
next[i] = k;
}
return next;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
这段代码来自 KMP 字符串匹配算法的代码实现. 这段代码完全符合刚提到的逻辑复杂, 实现难度大, 可读性差的特点, 但它并不违反 KISS 原则. 为什么这么说呢? KMP 算法以快速高效著称. 当需要处理长文本字符串匹配问题(几百 MB 大小文本内容的匹配), 或者字符串匹配是某个产品的核心功能(比如 Vim, Word 等文本编辑器), 又或者字符串匹配算法是系统性能瓶颈的时候, 就应该选择尽可能高效的 KMP 算法. 而 KMP 算法本身具有逻辑复杂, 实现难度大, 可读性差的特点. 本身就复杂的问题, 用复杂的方法解决, 并不违背 KISS 原则.
不过, 平时的项目开发中涉及的字符串匹配问题, 大部分都是针对比较小的文本. 在这种情况下, 直接调用编程语言提供的现成的字符串匹配函数就足够了. 如果非得用 KMP 算法, BM 算法来实现字符串匹配, 那就真的违背 KISS 原则了. 也就是说, 同样的代码, 在某个业务场景下满足 KISS 原则, 换一个应用场景可能就不满足了.
# 4.如何写出满足KISS原则的代码?
实际上, 前面已经讲到了一些方法. 这里稍微总结一下.
- 不要使用同事可能不懂的技术来实现代码. 比如前面例子中的正则表达式, 还有一些编程语言中过于高级的语法等.
- 不要重复造轮子, 要善于使用已经有的工具类库. 经验证明, 自己去实现这些类库, 出 bug 的概率会更高, 维护的成本也比较高.
- 不要过度优化. 不要过度使用一些奇技淫巧(比如, 位运算代替算术运算, 复杂的条件语句代替 if-else, 使用一些过于底层的函数等)来优化代码, 牺牲代码的可读性.
实际上, 代码是否足够简单是一个挺主观的评判. 同样的代码, 有的人觉得简单, 有的人觉得不够简单. 而往往自己编写的代码, 自己都会觉得够简单. 所以评判代码是否简单, 还有一个很有效的间接方法, 那就是 code review. 如果在 code review 的时候, 同事对你的代码有很多疑问, 那就说明你的代码有可能不够"简单", 需要优化啦.
在做开发的时候, 一定不要过度设计, 不要觉得简单的东西就没有技术含量. 实际上, 越是能用简单的方法解决复杂的问题, 越能体现一个人的能力.
# 5.YAGNI跟KISS说的是一回事吗?
YAGNI 原则的英文全称是: You Ain’t Gonna Need It. 直译就是: 你不会需要它. 这条原则也算是万金油了. 当用在软件开发中的时候, 它的意思是: 不要去设计当前用不到的功能; 不要去编写当前用不到的代码. 实际上, 这条原则的核心思想就是: 不要做过度设计.
比如, 系统暂时只用 Redis 存储配置信息, 以后可能会用到 ZooKeeper. 根据 YAGNI 原则, 在未用到 ZooKeeper 之前, 没必要提前编写这部分代码. 当然, 这并不是说就不需要考虑代码的扩展性. 还是要预留好扩展点, 等到需要的时候, 再去实现 ZooKeeper 存储配置信息这部分代码.
再比如, 不要在项目中提前引入不需要依赖的开发包. 对于 Java 程序员来说, 经常使用 Maven 或者 Gradle 来管理依赖的类库(library). 有些同事为了避免开发中 library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件, 提前往项目里引入大量常用的 library 包. 实际上, 这样的做法也是违背 YAGNI 原则的.
从刚刚的分析可以看出, YAGNI 原则跟 KISS 原则并非一回事儿. KISS 原则讲的是"如何做"的问题(尽量保持简单), 而 YAGNI 原则说的是"要不要做"的问题(当前不需要的就不要做) .
重点回顾
KISS 原则是保持代码可读和可维护的重要手段. KISS 原则中的"简单"并不是以代码行数来考量的. 代码行数越少并不代表代码越简单, 还要考虑逻辑复杂度, 实现难度, 代码的可读性等. 而且, 本身就复杂的问题, 用复杂的方法解决, 并不违背 KISS 原则. 除此之外, 同样的代码, 在某个业务场景下满足 KISS 原则, 换一个应用场景可能就不满足了.
对于如何写出满足 KISS 原则的代码, 这里总结了下面几条指导原则:
- 不要使用同事可能不懂的技术来实现代码;
- 不要重复造轮子, 要善于使用已经有的工具类库;
- 不要过度优化.
# 21-理论七:重复的代码就一定违背DRY吗?如何提高代码的复用性?
本节再学习 DRY 原则. 它的英文描述为: Don’t Repeat Yourself. 中文直译为: 不要重复自己. 将它应用在编程中, 可以理解为: 不要写重复的代码.
你可能会觉得, 这条原则非常简单, 非常容易应用. 只要两段代码长得一样, 那就是违反 DRY 原则了. 真的是这样吗? 答案是否定的. 这是很多人对这条原则存在的误解. 实际上, 重复的代码不一定违反 DRY 原则, 而且有些看似不重复的代码也有可能违反 DRY 原则.
# 1.DRY原则(Don't Repeat Yourself)
DRY 原则的定义非常简单, 就不再过度解读. 这里主要讲三种典型的代码重复情况, 它们分别是: 实现逻辑重复, 功能语义重复和代码执行重复. 这三种代码重复, 有的看似违反 DRY, 实际上并不违反; 有的看似不违反, 实际上却违反了.
# (1)实现逻辑重复
先来看下面这样一段代码是否违反了 DRY 原则. 如果违反了, 你觉得应该如何重构, 才能让它满足 DRY 原则? 如果没有违反, 那又是为什么呢?
public class UserAuthenticator {
public void authenticate(String username, String password) {
if (!isValidUsername(username)) {
// ...throw InvalidUsernameException...
}
if (!isValidPassword(password)) {
// ...throw InvalidPasswordException...
}
//... 省略其他代码...
}
private boolean isValidUsername(String username) {
// check not null, not empty
if (StringUtils.isBlank(username)) {
return false;
}
// check length: 4~64
int length = username.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(username)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = username.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}
private boolean isValidPassword(String password) {
// check not null, not empty
if (StringUtils.isBlank(password)) {
return false;
}
// check length: 4~64
int length = password.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(password)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = password.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
在代码中, 有两处非常明显的重复的代码片段: isValidUserName() 函数和 isValidPassword() 函数. 重复的代码被敲了两遍, 或者简单 copy-paste 了一下, 看起来明显违反 DRY 原则. 为了移除重复的代码, 对上面的代码做下重构, 将 isValidUserName() 函数和 isValidPassword() 函数, 合并为一个更通用的函数 isValidUserNameOrPassword(). 重构后的代码如下所示:
public class UserAuthenticatorV2 {
public void authenticate(String userName, String password) {
if (!isValidUsernameOrPassword(userName)) {
// ...throw InvalidUsernameException...
}
if (!isValidUsernameOrPassword(password)) {
// ...throw InvalidPasswordException...
}
}
private boolean isValidUsernameOrPassword(String usernameOrPassword) {
// 省略实现逻辑
// 跟原来的 isValidUsername() 或 isValidPassword() 的实现逻辑一样...
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
经过重构之后, 代码行数减少了, 也没有重复的代码了, 是不是更好了呢? 答案是否定的, 这可能跟你预期的不一样, 下面解释一下为什么.
单从名字上看, 就能发现, 合并之后的 isValidUserNameOrPassword() 函数, 负责两件事情: 验证用户名和验证密码, 违反了"单一职责原则"和"接口隔离原则" . 实际上, 即便将两个函数合并成 isValidUserNameOrPassword(), 代码仍然存在问题.
因为 isValidUserName() 和 isValidPassword() 两个函数, 虽然从代码实现逻辑上看起来是重复的, 但是从语义上并不重复. 所谓"语义不重复"指的是: 从功能上来看, 这两个函数干的是完全不重复的两件事情, 一个是校验用户名, 另一个是校验密码. 尽管在目前的设计中, 两个校验逻辑是完全一样的, 但如果按照第二种写法, 将两个函数的合并, 那就会存在潜在的问题. 在未来的某一天, 如果修改了密码的校验逻辑, 比如, 允许密码包含大写字符, 允许密码的长度为 8 到 64 个字符, 那这个时候, isValidUserName() 和 isValidPassword() 的实现逻辑就会不相同. 就要把合并后的函数, 重新拆成合并前的那两个函数.
尽管代码的实现逻辑是相同的, 但语义不同, 所以判定它并不违反 DRY 原则. 对于包含重复代码的问题, 可以通过抽象成更细粒度函数的方式来解决. 比如将校验只包含 a-z, 0-9, dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数.
# (2)功能语义重复
现在再来看另外一个例子. 在同一个项目代码中有下面两个函数: isValidIp() 和 checkIfIpValid(). 尽管两个函数的命名不同, 实现逻辑不同, 但功能是相同的, 都是用来判定 IP 地址是否合法的.
之所以在同一个项目中会有两个功能相同的函数, 那是因为这两个函数是由两个不同的同事开发的, 其中一个同事在不知道已经有了 isValidIp() 的情况下, 自己又定义并实现了同样用来校验 IP 地址是否合法的 checkIfIpValid() 函数.
那在同一项目代码中, 存在如下两个函数, 是否违反 DRY 原则呢?
public boolean isValidIp(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
public boolean checkIfIpValid(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i <mark> 0 && ipUnitIntValue </mark> 0) {
return false;
}
}
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
这个例子跟上个例子正好相反. 上一个例子是代码实现逻辑重复, 但语义不重复, 所以并不认为它违反了 DRY 原则. 而在这个例子中, 尽管两段代码的实现逻辑不重复, 但语义重复, 也就是功能重复, 所以认为它违反了 DRY 原则. 应该在项目中, 统一一种实现思路, 所有用到判断 IP 地址是否合法的地方, 都统一调用同一个函数.
假设不统一实现思路, 那有些地方调用了 isValidIp() 函数, 有些地方又调用了 checkIfIpValid() 函数, 这就会导致代码看起来很奇怪, 相当于给代码"埋坑", 给不熟悉这部分代码的同事增加了阅读的难度. 同事有可能研究了半天, 觉得功能是一样的, 但又有点疑惑, 觉得是不是有更高深的考量, 才定义了两个功能类似的函数, 最终发现居然是代码设计的问题.
除此之外, 如果哪天项目中 IP 地址是否合法的判定规则改变了, 比如: 255.255.255.255 不再被判定为合法的了, 相应地, 对 isValidIp() 的实现逻辑做了相应的修改, 但却忘记了修改 checkIfIpValid() 函数. 又或者, 压根就不知道还存在一个功能相同的 checkIfIpValid() 函数, 这样就会导致有些代码仍然使用老的 IP 地址判断逻辑, 导致出现一些莫名其妙的 bug.
# (3)代码执行重复
前两个例子一个是实现逻辑重复, 一个是语义重复, 再来看第三个例子. 其中, UserService 中 login() 函数用来校验用户登录是否成功. 如果失败, 就返回异常; 如果成功, 就返回用户信息. 具体代码如下所示:
public class UserService {
private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
public User login(String email, String password) {
boolean existed = userRepo.checkIfUserExisted(email, password);
if (!existed) {
// ... throw AuthenticationFailureException...
}
User user = userRepo.getUserByEmail(email);
return user;
}
}
public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}
//...query db to check if email&password exists...
}
public User getUserByEmail(String email) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
//...query db to get user by email...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
上面这段代码, 既没有逻辑重复, 也没有语义重复, 但仍然违反了 DRY 原则. 这是因为代码中存在"执行重复". 一块儿来看下, 到底哪些代码被重复执行了?
重复执行最明显的一个地方, 就是在 login() 函数中, email 的校验逻辑被执行了两次. 一次是在调用 checkIfUserExisted() 函数的时候, 另一次是调用 getUserByEmail() 函数的时候. 这个问题解决起来比较简单, 只需要将校验逻辑从 UserRepo 中移除, 统一放到 UserService 中就可以了.
除此之外, 代码中还有一处比较隐蔽的执行重复, 不知道你发现了没有? 实际上, login() 函数并不需要调用 checkIfUserExisted() 函数, 只需要调用一次 getUserByEmail() 函数, 从数据库中获取到用户的 email, password 等信息, 然后跟用户输入的 email, password 信息做对比, 依次判断是否登录成功.
实际上, 这样的优化是很有必要的. 因为 checkIfUserExisted() 函数和 getUserByEmail() 函数都需要查询数据库, 而数据库这类的 I/O 操作是比较耗时的. 在写代码的时候, 应当尽量减少这类 I/O 操作.
按照刚刚的修改思路, 把代码重构一下, 移除"重复执行"的代码, 只校验一次 email 和 password, 并且只查询一次数据库. 重构之后的代码如下所示:
public class UserService {
private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
public User login(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}
User user = userRepo.getUserByEmail(email);
if (user == null || !password.equals(user.getPassword()) {
// ... throw AuthenticationFailureException...
}
return user;
}
}
public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
//...query db to check if email&password exists
}
public User getUserByEmail(String email) {
//...query db to get user by email...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 2.代码复用性(Code Reusability)
在专栏的最开始, 我们有提到, 代码的复用性是评判代码质量的一个非常重要的标准. 当时只是点到为止, 没有展开讲解, 今天, 我再带你深入地学习一下这个知识点.
# (1)什么是代码的复用性?
首先来区分三个概念: 代码复用性(Code Reusability), 代码复用(Code Resue)和 DRY 原则.
代码复用表示一种行为: 在开发新功能的时候, 尽量复用已经存在的代码. 代码的可复用性表示一段代码可被复用的特性或能力: 在编写代码的时候, 让代码尽量可复用. DRY 原则是一条原则: 不要写重复的代码. 从定义描述上, 它们好像有点类似, 但深究起来, 三者的区别还是蛮大的.
**首先, "不重复"并不代表"可复用". **在一个项目代码中, 可能不存在任何重复的代码, 但也并不表示里面有可复用的代码, 不重复和可复用完全是两个概念. 所以, 从这个角度来说, DRY 原则跟代码的可复用性讲的是两回事.
**其次, "复用"和"可复用性"关注角度不同. ** 代码"可复用性"是从代码开发者的角度来讲的, "复用"是从代码使用者的角度来讲的. 比如, A 同事编写了一个 UrlUtils 类, 代码的"可复用性"很好. B 同事在开发新功能的时候, 直接"复用" A 同事编写的 UrlUtils 类.
尽管复用, 可复用性, DRY 原则这三者从理解上有所区别, 但实际上要达到的目的都是类似的, 都是为了减少代码量, 提高代码的可读性, 可维护性. 除此之外, 复用已经经过测试的老代码, bug 会比从零重新开发要少.
"复用" 这个概念不仅可以指导细粒度的模块, 类, 函数的设计开发, 实际上, 一些框架, 类库, 组件等的产生也都是为了达到复用的目的. 比如, Spring 框架, Google Guava 类库, UI 组件等等.
# (2)怎么提高代码复用性?
前面已经讲到过很多提高代码可复用性的手段, 今天算是集中总结一下, 具体如下.
减少代码耦合
对于高度耦合的代码, 当希望复用其中的一个功能, 想把这个功能的代码抽取出来成为一个独立的模块, 类或者函数的时候, 往往会发现牵一发而动全身. 移动一点代码, 就要牵连到很多其他相关的代码. 所以, 高度耦合的代码会影响到代码的复用性, 要尽量减少代码耦合.
满足单一职责原则
前面讲过, 如果职责不够单一, 模块, 类设计得大而全, 那依赖它的代码或者它依赖的代码就会比较多, 进而增加了代码的耦合. 根据上一点, 也就会影响到代码的复用性. 相反, 越细粒度的代码, 代码的通用性会越好, 越容易被复用.
模块化
这里的"模块", 不单单指一组类构成的模块, 还可以理解为单个类, 函数. 要善于将功能独立的代码, 封装成模块. 独立的模块就像一块一块的积木, 更加容易复用, 可以直接拿来搭建更加复杂的系统.
业务与非业务逻辑分离
越是跟业务无关的代码越是容易复用, 越是针对特定业务的代码越难复用. 所以, 为了复用跟业务无关的代码, 将业务和非业务逻辑代码分离, 抽取成一些通用的框架, 类库, 组件等.
通用代码下沉
从分层的角度来看, 越底层的代码越通用, 会被越多的模块调用, 越应该设计得足够可复用. 一般情况下, 在代码分层之后, 为了避免交叉调用导致调用关系混乱, 只允许上层代码调用下层代码及同层代码之间的调用, 杜绝下层代码调用上层代码. 所以, 通用的代码尽量下沉到更下层.
继承, 多态, 抽象, 封装
在讲面向对象特性的时候讲到, 利用继承, 可以将公共的代码抽取到父类, 子类复用父类的属性和方法. 利用多态, 可以动态地替换一段代码的部分逻辑, 让这段代码可复用. 除此之外, 抽象和封装, 从更加广义的层面, 而非狭义的面向对象特性的层面来理解的话, 越抽象, 越不依赖具体的实现, 越容易复用. 代码封装成模块, 隐藏可变的细节, 暴露不变的接口, 就越容易复用.
应用模板等设计模式
一些设计模式, 也能提高代码的复用性. 比如, 模板模式利用了多态来实现, 可以灵活地替换其中的部分代码, 整个流程模板代码可复用. 关于应用设计模式提高代码复用性这一部分, 留在后面慢慢来讲解.
除了刚刚讲到的几点, 还有一些跟编程语言相关的特性, 也能提高代码的复用性, 比如泛型编程等. 实际上, 除了上面讲到的这些方法之外, 复用意识也非常重要. 在写代码的时候, 要多去思考一下, 这个部分代码是否可以抽取出来, 作为一个独立的模块, 类或者函数供多处使用. 在设计每个模块, 类, 函数的时候, 要像设计一个外部 API 那样, 去思考它的复用性.
实际上, 编写可复用的代码并不简单. 如果在编写代码的时候, 已经有复用的需求场景, 那根据复用的需求去开发可复用的代码, 可能还不算难. 但是, 如果当下并没有复用的需求, 只是希望现在编写的代码具有可复用的特点, 能在未来某个同事开发某个新功能的时候复用得上. 在这种没有具体复用需求的情况下, 就需要去预测将来代码会如何复用, 这就比较有挑战了.
实际上, 除非有非常明确的复用需求, 否则, 为了暂时用不到的复用需求, 花费太多的时间, 精力, 投入太多的开发成本, 并不是一个值得推荐的做法. 这也违反之前讲到的 YAGNI 原则.
除此之外, 有一个著名的原则, 叫作 "Rule of Three". 这条原则可以用在很多行业和场景中, 可以自己去研究一下. 如果把这个原则用在这里, 那就是说, 在第一次写代码的时候, 如果当下没有复用的需求, 而未来的复用需求也不是特别明确, 并且开发可复用代码的成本比较高, 那就不需要考虑代码的复用性. 在之后开发新的功能的时候, 发现可以复用之前写的这段代码, 那就重构这段代码, 让其变得更加可复用.
也就是说, 第一次编写代码的时候, 不考虑复用性; 第二次遇到复用场景的时候, 再进行重构使其复用. 需要注意的是, "Rule of Three" 中的 "Three" 并不是真的就指确切的 "三", 这里就是指 "二".
重点回顾
1.DRY原则
今天讲了三种代码重复的情况: 实现逻辑重复, 功能语义重复, 代码执行重复. 实现逻辑重复, 但功能语义不重复的代码, 并不违反 DRY 原则. 实现逻辑不重复, 但功能语义重复的代码, 也算是违反 DRY 原则. 除此之外, 代码执行重复也算是违反 DRY 原则.
2.代码复用性
总结一下提高代码可复用性的一些方法, 有以下几点.
- 减少代码耦合
- 满足单一职责原则
- 模块化
- 业务与非业务逻辑分离
- 通用代码下沉
- 继承, 多态, 抽象, 封装
- 应用模板等设计模式
实际上, 除了上面讲到的这些方法之外, 复用意识也非常重要. 在设计每个模块, 类, 函数的时候, 要像设计一个外部 API 一样去思考它的复用性.
在第一次写代码的时候, 如果当下没有复用的需求, 而未来的复用需求也不是特别明确, 并且开发可复用代码的成本比较高, 那就不需要考虑代码的复用性. 在之后开发新的功能的时候, 发现可以复用之前写的这段代码, 那就重构这段代码, 让其变得更加可复用.
相比于代码的可复用性, DRY 原则适用性更强一些. 可以不写可复用的代码, 但一定不能写重复的代码.
# 22-理论八:如何用迪米特法则(LOD)实现"高内聚,松耦合"?
本节讲最后一个设计原则: 迪米特法则. 尽管它不像 SOLID, KISS, DRY 原则那样, 人尽皆知, 但它却非常实用. 利用这个原则, 能够实现代码的"高内聚, 松耦合". 本节就围绕下面几个问题, 并结合两个代码实战案例, 来深入地学习这个法则.
- 什么是"高内聚, 松耦合"?
- 如何利用迪米特法则来实现"高内聚, 松耦合"?
- 有哪些代码设计是明显违背迪米特法则的? 对此又该如何重构?
# 1.何为"高内聚,松耦合"?
"高内聚, 松耦合" 是一个非常重要的设计思想, 能够有效地提高代码的可读性和可维护性, 缩小功能改动导致的代码改动范围. 实际上, 前面已经多次提到过这个设计思想. 很多设计原则都以实现代码的"高内聚, 松耦合"为目的, 比如单一职责原则, 基于接口而非实现编程等.
实际上, "高内聚, 松耦合" 是一个比较通用的设计思想, 可以用来指导不同粒度代码的设计与开发, 比如系统, 模块, 类, 甚至是函数, 也可以应用到不同的开发场景中, 比如微服务, 框架, 组件, 类库等. 为了方便讲解, 接下来以"类"作为这个设计思想的应用对象来展开讲解, 其他应用场景可以自行类比.
在这个设计思想中, "高内聚" 用来指导类本身的设计, "松耦合" 用来指导类与类之间依赖关系的设计. 不过, 这两者并非完全独立不相干. 高内聚有助于松耦合, 松耦合又需要高内聚的支持.
**那到底什么是"高内聚"呢? **
所谓高内聚, 就是指相近的功能应该放到同一个类中, 不相近的功能不要放到同一个类中. 相近的功能往往会被同时修改, 放到同一个类中, 修改会比较集中, 代码容易维护. 实际上, 前面讲过的单一职责原则是实现代码高内聚非常有效的设计原则.
**再来看一下, 什么是"松耦合"? **
所谓松耦合是说, 在代码中, 类与类之间的依赖关系简单清晰. 即使两个类有依赖关系, 一个类的代码改动不会或者很少导致依赖类的代码改动. 实际上, 前面讲的依赖注入, 接口隔离, 基于接口而非实现编程, 以及今天讲的迪米特法则, 都是为了实现代码的松耦合.
**最后来看一下, "内聚"和"耦合"之间的关系. **
前面也提到, "高内聚"有助于"松耦合", 同理, "低内聚"也会导致"紧耦合". 关于这一点, 我画了一张对比图来解释. 图中左边部分的代码结构是"高内聚, 松耦合"; 右边部分正好相反, 是"低内聚, 紧耦合".

图中左边部分的代码设计中, 类的粒度比较小, 每个类的职责都比较单一. 相近的功能都放到了一个类中, 不相近的功能被分割到了多个类中. 这样类更加独立, 代码的内聚性更好. 因为职责单一, 所以每个类被依赖的类就会比较少, 代码低耦合. 一个类的修改, 只会影响到一个依赖类的代码改动. 只需要测试这一个依赖类是否还能正常工作就行了.
图中右边部分的代码设计中, 类粒度比较大, 低内聚, 功能大而全, 不相近的功能放到了一个类中. 这就导致很多其他类都依赖这个类. 当修改这个类的某一个功能代码的时候, 会影响依赖它的多个类. 需要测试这三个依赖类, 是否还能正常工作. 这也就是所谓的"牵一发而动全身".
除此之外, 从图中也可以看出, 高内聚, 低耦合的代码结构更加简单, 清晰, 相应地, 在可维护性和可读性上确实要好很多.
# 2."迪米特法则"理论描述
迪米特法则的英文翻译是: Law of Demeter, 缩写是 LOD. 单从这个名字上来看, 完全猜不出这个原则讲的是什么. 不过, 它还有另外一个更加达意的名字, 叫作最小知识原则, 英文翻译为: The Least Knowledge Principle.
关于这个设计原则, 先来看一下它的英文定义:
Each unit should have only limited knowledge about other units: only units "closely" related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.
把它直译成中文, 就是下面这个样子:
每个模块(unit)只应该了解那些与它关系密切的模块(units: only units "closely" related to the current unit)的有限知识(knowledge). 或者说, 每个模块只和自己的朋友"说话"(talk), 不和陌生人"说话"(talk).
之前讲过, 大部分设计原则和思想都非常抽象, 有各种各样的解读, 要想灵活地应用到实际的开发中, 需要有实战经验的积累. 迪米特法则也不例外. 所以, 我结合我自己的理解和经验, 对刚刚的定义重新描述一下. 注意, 为了统一讲解, 我把定义描述中的"模块"替换成了"类".
不该有直接依赖关系的类之间, 不要有依赖; 有依赖关系的类之间, 尽量只依赖必要的接口(也就是定义中的"有限知识").
从上面的描述中可以看出, 迪米特法则包含前后两部分, 这两部分讲的是两件事情, 下面用两个实战案例分别来解读一下.
# 3.理论解读与代码实战一
先来看这条原则中的前半部分, **"不该有直接依赖关系的类之间, 不要有依赖". ** 举个例子解释一下.
这个例子实现了简化版的搜索引擎爬取网页的功能. 代码中包含三个主要的类. 其中, NetworkTransporter 类负责底层网络通信, 根据请求获取数据; HtmlDownloader 类用来通过 URL 获取网页; Document 表示网页文档, 后续的网页内容抽取, 分词, 索引都是以此为处理对象. 具体的代码实现如下所示:
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(HtmlRequest htmlRequest) {
//...
}
}
public class HtmlDownloader {
private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
public Html downloadHtml(String url) {
Byte[] rawHtml = transporter.send(new HtmlRequest(url));
return new Html(rawHtml);
}
}
public class Document {
private Html html;
private String url;
public Document(String url) {
this.url = url;
HtmlDownloader downloader = new HtmlDownloader();
this.html = downloader.downloadHtml(url);
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
这段代码虽然"能用", 能实现想要的功能, 但是它不够"好用", 有比较多的设计缺陷.
**首先来看 NetworkTransporter 类. ** 作为一个底层网络通信类, 我们希望它的功能尽可能通用, 而不只是服务于下载 HTML, 所以不应该直接依赖太具体的发送对象 HtmlRequest. 从这一点上讲, NetworkTransporter 类的设计违背迪米特法则, 依赖了不该有直接依赖关系的 HtmlRequest 类.
应该如何进行重构, 让 NetworkTransporter 类满足迪米特法则呢? 这里有个形象的比喻. 假如你现在要去商店买东西, 你肯定不会直接把钱包给收银员, 让收银员自己从里面拿钱, 而是从钱包里把钱拿出来交给收银员. 这里的 HtmlRequest 对象就相当于钱包, HtmlRequest 里的 address 和 content 对象就相当于钱. 应该把 address 和 content 交给 NetworkTransporter, 而非是直接把 HtmlRequest 交给 NetworkTransporter. 根据这个思路, NetworkTransporter 重构之后的代码如下所示:
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(String address, Byte[] data) {
//...
}
}
2
3
4
5
6
**再来看 HtmlDownloader 类. ** 这个类的设计没有问题. 不过, 我们修改了 NetworkTransporter 的 send() 函数的定义, 而这个类用到了 send() 函数, 所以需要对它做相应的修改, 修改后的代码如下所示:
public class HtmlDownloader {
private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
// HtmlDownloader 这里也要有相应的修改
public Html downloadHtml(String url) {
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(
htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}
2
3
4
5
6
7
8
9
10
11
**最后, 来看下 Document 类. ** 这个类的问题比较多, 主要有三点. 第一, 构造函数中的 downloader.downloadHtml() 逻辑复杂, 耗时长, 不应该放到构造函数中, 会影响代码的可测试性. 代码的可测试性后面会讲到, 这里先知道有这回事就可以了. 第二, HtmlDownloader 对象在构造函数中通过 new 来创建, 违反了基于接口而非实现编程的设计思想, 也会影响到代码的可测试性. 第三, 从业务含义上来讲, Document 网页文档没必要依赖 HtmlDownloader 类, 违背了迪米特法则.
虽然 Document 类的问题很多, 但修改起来比较简单, 只要一处改动就可以解决所有问题. 修改之后的代码如下所示:
public class Document {
private Html html;
private String url;
public Document(String url, Html html) {
this.html = html;
this.url = url;
}
//...
}
// 通过一个工厂方法来创建 Document
public class DocumentFactory {
private HtmlDownloader downloader;
public DocumentFactory(HtmlDownloader downloader) {
this.downloader = downloader;
}
public Document createDocument(String url) {
Html html = downloader.downloadHtml(url);
return new Document(url, html);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 4.理论解读与代码实战二
现在, 再来看一下这条原则中的后半部分: "有依赖关系的类之间, 尽量只依赖必要的接口". 还是结合一个例子来讲解. 下面这段代码非常简单, Serialization 类负责对象的序列化和反序列化. 提醒一下, 有个类似的例子在之前讲过, 可以结合着一块儿看一下.
public class Serialization {
public String serialize(Object object) {
String serializedResult = ...;
//...
return serializedResult;
}
public Object deserialize(String str) {
Object deserializedResult = ...;
//...
return deserializedResult;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
单看这个类的设计, 没有一点问题. 不过, 如果把它放到一定的应用场景里, 那就还有继续优化的空间. 假设在的项目中, 有些类只用到了序列化操作, 而另一些类只用到反序列化操作. 那基于迪米特法则后半部分 "有依赖关系的类之间, 尽量只依赖必要的接口", 只用到序列化操作的那部分类不应该依赖反序列化接口. 同理, 只用到反序列化操作的那部分类不应该依赖序列化接口.
根据这个思路, 应该将 Serialization 类拆分为两个更小粒度的类, 一个只负责序列化(Serializer 类), 一个只负责反序列化(Deserializer 类). 拆分之后, 使用序列化操作的类只需要依赖 Serializer 类, 使用反序列化操作的类只需要依赖 Deserializer 类. 拆分之后的代码如下所示:
public class Serializer {
public String serialize(Object object) {
String serializedResult = ...;
// ...
return serializedResult;
}
}
public class Deserializer {
public Object deserialize(String str) {
Object deserializedResult = ...;
// ...
return deserializedResult;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
不知道你有没有看出来, 尽管拆分之后的代码更能满足迪米特法则, 但却违背了高内聚的设计思想. 高内聚要求相近的功能要放到同一个类中, 这样可以方便功能修改的时候, 修改的地方不至于过于分散. 对于刚刚这个例子来说, 如果修改了序列化的实现方式, 比如从 JSON 换成了 XML, 那反序列化的实现逻辑也需要一并修改. 在未拆分的情况下, 只需要修改一个类即可. 在拆分之后, 需要修改两个类. 显然, 这种设计思路的代码改动范围变大了.
如果既不想违背高内聚的设计思想, 也不想违背迪米特法则, 该如何解决这个问题呢? 实际上, 通过引入两个接口就能轻松解决这个问题, 具体的代码如下所示. 实际上, 在讲到"接口隔离原则"的时候, 第三个例子就使用了类似的实现思路, 可以结合着一块儿来看.
public interface Serializable {
String serialize(Object object);
}
public interface Deserializable {
Object deserialize(String text);
}
public class Serialization implements Serializable, Deserializable {
@Override
public String serialize(Object object) {
String serializedResult = ...;
// ...
return serializedResult;
}
@Override
public Object deserialize(String str) {
Object deserializedResult = ...;
// ...
return deserializedResult;
}
}
public class DemoClass1 {
private Serializable serializer;
public Demo(Serializable serializer) {
this.serializer = serializer;
}
//...
}
public class DemoClass2 {
private Deserializable deserializer;
public Demo(Deserializable deserializer) {
this.deserializer = deserializer;
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
尽管还是要往 DemoClass1 的构造函数中, 传入包含序列化和反序列化的 Serialization 实现类, 但是依赖的 Serializable 接口只包含序列化操作, DemoClass1 无法使用 Serialization 类中的反序列化接口, 对反序列化操作无感知, 这也就符合了迪米特法则后半部分所说的"依赖有限接口"的要求.
实际上, 上面的的代码实现思路, 也体现了"基于接口而非实现编程"的设计原则, 结合迪米特法则, 可以总结出一条新的设计原则, 那就是 "基于最小接口而非最大实现编程". 有些同学之前问, 新的设计模式和设计原则是怎么创造出来的, 实际上, 就是在大量的实践中, 针对开发痛点总结归纳出来的套路.
对于实战二最终的设计思路, 你有没有什么不同的观点呢?
整个类只包含序列化和反序列化两个操作, 只用到序列化操作的使用者, 即便能够感知到仅有的一个反序列化函数, 问题也不大. 那为了满足迪米特法则, 将一个非常简单的类, 拆分出两个接口, 是否有点过度设计的意思呢?
设计原则本身没有对错, 只有能否用对之说. 不要为了应用设计原则而应用设计原则, 在应用设计原则的时候, 一定要具体问题具体分析.
对于刚刚这个 Serialization 类来说, 只包含两个操作, 确实没有太大必要拆分成两个接口. 但是, 如果对 Serialization 类添加更多的功能, 实现更多更好用的序列化, 反序列化函数, 来重新考虑一下这个问题. 修改之后的具体的代码如下:
public class Serializer { // 参看 JSON 的接口定义
public String serialize(Object object) { //... }
public String serializeMap(Map map) { //... }
public String serializeList(List list) { //... }
public Object deserialize(String objectString) { //... }
public Map deserializeMap(String mapString) { //... }
public List deserializeList(String listString) { //... }
}
2
3
4
5
6
7
8
9
在这种场景下, 第二种设计思路要更好些. 因为基于之前的应用场景来说, 大部分代码只需要用到序列化的功能. 对于这部分使用者, 没必要了解反序列化的 "知识", 而修改之后的 Serialization 类, 反序列化的 "知识", 从一个函数变成了三个. 一旦任一反序列化操作有代码改动, 都需要检查, 测试所有依赖 Serialization 类的代码是否还能正常工作. 为了减少耦合和测试工作量, 应该按照迪米特法则, 将反序列化和序列化的功能隔离开来.
重点回顾
**1.如何理解"高内聚, 松耦合"? **
"高内聚, 松耦合"是一个非常重要的设计思想, 能够有效提高代码的可读性和可维护性, 缩小功能改动导致的代码改动范围. "高内聚"用来指导类本身的设计, "松耦合"用来指导类与类之间依赖关系的设计.
所谓高内聚, 就是指相近的功能应该放到同一个类中, 不相近的功能不要放到同一类中. 相近的功能往往会被同时修改, 放到同一个类中, 修改会比较集中. 所谓松耦合指的是, 在代码中, 类与类之间的依赖关系简单清晰. 即使两个类有依赖关系, 一个类的代码改动也不会或者很少导致依赖类的代码改动.
**2.如何理解"迪米特法则"? **
不该有直接依赖关系的类之间, 不要有依赖; 有依赖关系的类之间, 尽量只依赖必要的接口. 迪米特法则是希望减少类之间的耦合, 让类越独立越好. 每个类都应该少了解系统的其他部分. 一旦发生变化, 需要了解这一变化的类就会比较少.
# 23-实战一(上):针对业务系统的开发,如何做需求分析和设计?
对于一个工程师来说, 如果要追求长远发展, 就不能一直只把自己放在执行者的角色, 不能只是一个代码实现者, 还要有独立负责一个系统的能力, 能端到端(end to end)开发一个完整的系统. 这其中的工作就包括: 前期的需求沟通分析, 中期的代码设计实现, 后期的系统上线维护等.
前面提到过, 大部分工程师都是做业务开发的. 很多工程师都觉得, 做业务开发没啥技术含量, 没有成长, 就是简单的 CRUD, 翻译业务逻辑, 根本用不上专栏中讲的设计原则, 思想, 模式.
所以, 针对这两个普遍的现象, 今天通过一个积分兑换系统的开发实战, 一方面展示一个业务系统从需求分析到上线维护的整个开发套路, 让你能举一反三地应用到所有其他系统的开发中, 另一方面也展示在看似没有技术含量的业务开发中, 实际上都蕴含了哪些设计原则, 思想, 模式.
# 1.需求分析
积分是一种常见的营销手段, 很多产品都会通过它来促进消费, 增加用户粘性, 比如淘宝积分, 信用卡积分, 商场消费积分等等. 假设你是一家类似淘宝这样的电商平台的工程师, 平台暂时还没有积分系统. Leader 希望由你来负责开发这样一个系统, 你会如何来做呢?
你可能会说, 只要产品经理给我产品设计文档(PRD), 线框图, 我照着实现就可以了. 我觉得, 这种想法有点狭隘. 我认为, 技术人员应该更多地参与到产品设计中. 在 Google 工作的时候, 我很明显能感受到, Google 工程师跟其他公司工程师有一个很大区别, 那就是大部分人都具备产品思维, 并不是完全的 "技术控". 所以, Google 很多产品的初期设计都是工程师来完成的, 在产品发展壮大到一定程度的时候, 才会引入产品经理的角色.
那你可能要问了, 作为技术人, 该怎么做产品设计呢? 首先, 一定不要自己一个人闷头想. 一方面, 这样做很难想全面. 另一方面, 从零开始设计也比较浪费时间. 所以, 要学会"借鉴". 爱因斯坦说过, "创造的一大秘诀是要懂得如何隐藏你的来源". 你看大师都含蓄地表达了"借鉴"的重要性, 我们也没有必要因为"借鉴"而感到不好意思了.
可以找几个类似的产品, 比如淘宝, 看看它们是如何设计积分系统的, 然后借鉴到我们的产品中. 你可以自己亲自用用淘宝, 看看积分是怎么使用的, 也可以直接百度一下"淘宝积分规则". 基于这两个输入, 基本上就大致能摸清楚积分系统该如何设计了. 除此之外, 还要充分了解自己公司的产品, 将借鉴来的东西糅合在自己的产品中, 并做适当的微创新.
笼统地来讲, 积分系统无外乎就两个大的功能点, 一个是赚取积分, 另一个是消费积分. 赚取积分功能包括积分赚取渠道, 比如下订单, 每日签到, 评论等; 还包括积分兑换规则, 比如订单金额与积分的兑换比例, 每日签到赠送多少积分等. 消费积分功能包括积分消费渠道, 比如抵扣订单金额, 兑换优惠券, 积分换购, 参与活动扣积分等; 还包括积分兑换规则, 比如多少积分可以换算成抵扣订单的多少金额, 一张优惠券需要多少积分来兑换等等.
刚刚给出的只是非常笼统, 粗糙的功能需求. 在实际情况中, 肯定还有一些业务细节需要考虑, 比如积分的有效期问题. 对于这些业务细节, 还是那句话, 闷头拍脑袋想是想不全面的. 以防遗漏, 还是要有方法可寻. 那除了刚刚讲的"借鉴"的思路之外, 我还喜欢通过产品的线框图, 用户用例(user case )或者叫用户故事(user story)来细化业务流程, 挖掘一些比较细节的, 不容易想到的功能点.
这里重点说一下用户用例. 用户用例有点儿类似后面要讲的单元测试用例. 它侧重情景化, 其实就是模拟用户如何使用我们的产品, 描述用户在一个特定的应用场景里的一个完整的业务操作流程. 所以它包含更多的细节, 且更加容易被人理解. 比如, 有关积分有效期的用户用例, 可以进行如下的设计:
- 用户在获取积分的时候, 会告知积分的有效期;
- 用户在使用积分的时候, 会优先使用快过期的积分;
- 用户在查询积分明细的时候, 会显示积分的有效期和状态(是否过期);
- 用户在查询总可用积分的时候, 会排除掉过期的积分.
通过上面讲的方法, 就可以将功能需求大致弄清楚了. 积分系统的需求实际上并不复杂, 我总结罗列了一下, 如下所示.
积分赚取和兑换规则
积分的赚取渠道包括: 下订单, 每日签到, 评论等.
积分兑换规则可以是比较通用的. 比如, 签到送 10 积分. 再比如, 按照订单总金额的 10% 兑换成积分, 也就是 100 块钱的订单可以积累 10 积分. 除此之外, 积分兑换规则也可以是比较细化的. 比如, 不同的店铺, 不同的商品, 可以设置不同的积分兑换比例.
对于积分的有效期, 可以根据不同渠道, 设置不同的有效期. 积分到期之后会作废; 在消费积分的时候, 优先使用快到期的积分.
积分消费和兑换规则
积分的消费渠道包括: 抵扣订单金额, 兑换优惠券, 积分换购, 参与活动扣积分等.
可以根据不同的消费渠道, 设置不同的积分兑换规则. 比如积分换算成消费抵扣金额的比例是 10%, 也就是 10 积分可以抵扣 1 块钱; 100 积分可以兑换 15 块钱的优惠券等.
积分及其明细查询
查询用户的总积分, 以及赚取积分和消费积分的历史记录.
# 2.系统设计
面向对象设计聚焦在代码层面(主要是针对类), 那系统设计就是聚焦在架构层面(主要是针对模块), 两者有很多相似之处. 很多设计原则和思想不仅仅可以应用到代码设计中, 还能用到架构设计中. 还记得面向对象设计的四个步骤吗? 实际上, 也可以借鉴那个过程来做系统设计.
# (1)合理地将功能划分到不同模块
前面讲到面向对象设计的时候提到, 面向对象设计的本质就是把合适的代码放到合适的类中. 合理地划分代码可以实现代码的高内聚, 低耦合, 类与类之间的交互简单清晰, 代码整体结构一目了然, 那代码的质量就不会差到哪里去. 类比面向对象设计, 系统设计实际上就是将合适的功能放到合适的模块中. 合理地划分模块也可以做到模块层面的高内聚, 低耦合, 架构整洁清晰.
对于前面罗列的所有功能点, 有下面三种模块划分方法.
第一种划分方式是: 积分赚取渠道及兑换规则, 消费渠道及兑换规则的管理和维护(增删改查), 不划分到积分系统中, 而是放到更上层的营销系统中. 这样积分系统就会变得非常简单, 只需要负责增加积分, 减少积分, 查询积分, 查询积分明细等这几个工作. 举个例子解释一下. 比如, 用户通过下订单赚取积分. 订单系统通过异步发送消息或者同步调用接口的方式, 告知营销系统订单交易成功. 营销系统根据拿到的订单信息, 查询订单对应的积分兑换规则(兑换比例, 有效期等), 计算得到订单可兑换的积分数量, 然后调用积分系统的接口给用户增加积分.
第二种划分方式是: 积分赚取渠道及兑换规则, 消费渠道及兑换规则的管理和维护, 分散在各个相关业务系统中, 比如订单系统, 评论系统, 签到系统, 换购商城, 优惠券系统等. 还是刚刚那个下订单赚取积分的例子, 在这种情况下, 用户下订单成功之后, 订单系统根据商品对应的积分兑换比例, 计算所能兑换的积分数量, 然后直接调用积分系统给用户增加积分.
第三种划分方式是: 所有的功能都划分到积分系统中, 包括积分赚取渠道及兑换规则, 消费渠道及兑换规则的管理和维护. 还是同样的例子, 用户下订单成功之后, 订单系统直接告知积分系统订单交易成功, 积分系统根据订单信息查询积分兑换规则, 给用户增加积分.
怎么判断哪种模块划分合理呢? 实际上, 可以反过来通过看它是否符合高内聚, 低耦合特性来判断. 如果一个功能的修改或添加, 经常要跨团队, 跨项目, 跨系统才能完成, 那说明模块划分的不够合理, 职责不够清晰, 耦合过于严重.
除此之外, 为了避免业务知识的耦合, 让下层系统更加通用, 一般来讲, 不希望下层系统(也就是被调用的系统)包含太多上层系统(也就是调用系统)的业务信息, 但是, 可以接受上层系统包含下层系统的业务信息. 比如, 订单系统, 优惠券系统, 换购商城等作为调用积分系统的上层系统, 可以包含一些积分相关的业务信息. 但是, 反过来, 积分系统中最好不要包含太多跟订单, 优惠券, 换购等相关的信息.
所以, 综合考虑, 我们更倾向于第一种和第二种模块划分方式. 但是, 不管选择这两种中的哪一种, 积分系统所负责的工作是一样的, 只包含积分的增, 减, 查询, 以及积分明细的记录和查询.
# (2)设计模块与模块之间的交互关系
在面向对象设计中, 类设计好之后, 需要设计类之间的交互关系. 类比到系统设计, 系统职责划分好之后, 接下来就是设计系统之间的交互, 也就是确定有哪些系统跟积分系统之间有交互以及如何进行交互.
比较常见的系统之间的交互方式有两种, 一种是同步接口调用, 另一种是利用消息中间件异步调用. 第一种方式简单直接, 第二种方式的解耦效果更好.
比如, 用户下订单成功之后, 订单系统推送一条消息到消息中间件, 营销系统订阅订单成功消息, 触发执行相应的积分兑换逻辑. 这样订单系统就跟营销系统完全解耦, 订单系统不需要知道任何跟积分相关的逻辑, 而营销系统也不需要直接跟订单系统交互.
除此之外, 上下层系统之间的调用倾向于通过同步接口, 同层之间的调用倾向于异步消息调用. 比如, 营销系统和积分系统是上下层关系, 它们之间就比较推荐使用同步接口调用.
# (3)设计模块的接口,数据库,业务模型
刚刚讲了模块的功能划分, 模块之间的交互的设计, 现在再来看, 模块本身如何来设计. 实际上, 业务系统本身的设计无外乎有这样三方面的工作要做: 接口设计, 数据库设计和业务模型设计. 这部分的具体内容放到下一节中跟实现一块进行讲解.
重点回顾
技术人也要有一些产品思维. 对于产品设计, 需求分析, 要学会"借鉴", 一定不要自己闷头想. 一方面这样做很难想全面, 另一方面从零开始设计也比较浪费时间. 除此之外, 还可以通过线框图和用户用例来细化业务流程, 挖掘一些比较细节的, 不容易想到的功能点.
面向对象设计聚焦在代码层面(主要是针对类), 那系统设计就是聚焦在架构层面(主要是针对模块), 两者有很多相似之处. 很多设计原则和思想不仅仅可以应用到代码设计中, 还能用到架构设计中. 实际上, 可以借鉴面向对象设计的步骤, 来做系统设计.
面向对象设计的本质就是把合适的代码放到合适的类中. 合理地划分代码可以实现代码的高内聚, 低耦合, 类与类之间的交互简单清晰, 代码整体结构一目了然. 类比面向对象设计, 系统设计实际上就是将合适的功能放到合适的模块中. 合理地划分模块也可以做到模块层面的高内聚, 低耦合, 架构整洁清晰. 在面向对象设计中, 类设计好之后, 需要设计类之间的交互关系. 类比到系统设计, 系统职责划分好之后, 接下来就是设计系统之间的交互了.
# 24-实战一(下):如何实现一个遵从设计原则的积分兑换系统?
上一节讲了积分系统的需求分析和系统设计. 今天来讲它的代码实现.
上一节把积分赚取和消费的渠道和规则的管理维护工作, 划分到了上层系统中, 所以积分系统的功能变得非常简单. 相应地, 代码实现也比较简单. 如果你有一定的项目开发经验, 那实现这样一个系统, 对你来说并不是件难事.
所以今天讲解的重点, 并不是教你如何来实现积分系统的每个功能, 每个接口, 更不是教你如何编写 SQL 语句来增删改查数据, 而是给你展示一些更普适的开发思想. 比如, 为什么要分 MVC 三层来开发? 为什么要针对每层定义不同的数据对象? 最后还会总结这其中都蕴含哪些设计原则和思想, 让你知其然知其所以然, 做到真正地透彻理解.
# 1.业务开发包括哪些工作?
实际上, 平时做业务系统的设计与开发, 无外乎有这样三方面的工作要做: 接口设计, 数据库设计和业务模型设计(也就是业务逻辑).
数据库和接口的设计非常重要, 一旦设计好并投入使用之后, 这两部分都不能轻易改动. 改动数据库表结构, 需要涉及数据的迁移和适配; 改动接口, 需要推动接口的使用者作相应的代码修改. 这两种情况, 即便是微小的改动, 执行起来都会非常麻烦. 因此, 在设计接口和数据库的时候, 一定要多花点心思和时间, 切不可过于随意. 相反, 业务逻辑代码侧重内部实现, 不涉及被外部依赖的接口, 也不包含持久化的数据, 所以对改动的容忍性更大.
**针对积分系统, 先来看如何设计数据库. **
数据库的设计比较简单. 实际上, 只需要一张记录积分流水明细的表就可以了. 表中记录积分的赚取和消费流水. 用户积分的各种统计数据, 比如总积分, 总可用积分等, 都可以通过这张表来计算得到.

**接下来, 再来看如何设计积分系统的接口. **
接口设计要符合单一职责原则, 粒度越小通用性就越好. 但是, 接口粒度太小也会带来一些问题. 比如, 一个功能的实现要调用多个小接口, 一方面如果接口调用走网络(特别是公网), 多次远程接口调用会影响性能; 另一方面, 本该在一个接口中完成的原子操作, 现在分拆成多个小接口来完成, 就可能会涉及分布式事务的数据一致性问题(一个接口执行成功了, 但另一个接口执行失败了). 所以为了兼顾易用性和性能, 可以借鉴 facade(外观)设计模式, 在职责单一的细粒度接口之上, 再封装一层粗粒度的接口给外部使用.
对于积分系统来说, 需要设计如下这样几个接口.

**最后, 来看业务模型的设计. **
前面讲到, 从代码实现角度来说, 大部分业务系统的开发都可以分为 Controller, Service, Repository 三层. Controller 层负责接口暴露, Repository 层负责数据读写, Service 层负责核心业务逻辑, 也就是这里说的业务模型.
除此之外, 前面还提到两种开发模式, 基于贫血模型的传统开发模式和基于充血模型的 DDD 开发模式. 前者是一种面向过程的编程风格, 后者是一种面向对象的编程风格. 不管是 DDD 还是 OOP, 高级开发模式的存在一般都是为了应对复杂系统, 应对系统的复杂性. 对于要开发的积分系统来说, 因为业务相对比较简单, 所以选择简单的基于贫血模型的传统开发模式就足够了.
从开发的角度来说, 可以把积分系统作为一个独立的项目, 来独立开发, 也可以跟其他业务代码(比如营销系统)放到同一个项目中进行开发. 从运维的角度来说, 可以将它跟其他业务一块部署, 也可以作为一个微服务独立部署. 具体选择哪种开发和部署方式, 可以参考公司当前的技术架构来决定.
实际上, 积分系统业务比较简单, 代码量也不多, 我更倾向于将它跟营销系统放到一个项目中开发部署. 只要做好代码的模块化和解耦, 让积分相关的业务代码跟其他业务代码之间边界清晰, 没有太多耦合, 后期如果需要将它拆分成独立的项目来开发部署, 那也并不困难.
这样一个简单的业务功能的开发, 并没有太大难度. 所以具体的代码实现就不在专栏中给出了. 接下来的内容, 才是这一节的重点.
# 2.为什么要分MVC三层开发?
刚刚提到, 大部分业务系统的开发都可以分为三层: Contoller 层, Service 层, Repository 层. 对于这种分层方式, 我相信大部分人都很认同, 甚至成为了一种开发习惯, 但你有没有想过, 为什么要分层开发? 很多业务都比较简单, 一层代码搞定所有的数据读取, 业务逻辑, 接口暴露不好吗? 你可以把它作为一道面试题, 试着自己思考下, 然后再看下面的讲解.
对于这个问题, 我总结了以下几点原因.
分层能起到代码复用的作用
同一个 Repository 可能会被多个 Service 来调用, 同一个 Service 可能会被多个 Controller 调用. 比如, UserService 中的 getUserById() 接口封装了通过 ID 获取用户信息的逻辑, 这部分逻辑可能会被 UserController 和 AdminController 等多个 Controller 使用. 如果没有 Service 层, 每个 Controller 都要重复实现这部分逻辑, 显然会违反 DRY 原则.
分层能起到隔离变化的作用
分层体现了一种抽象和封装的设计思想. 比如, Repository 层封装了对数据库访问的操作, 提供了抽象的数据访问接口. 基于接口而非实现编程的设计思想, Service 层使用 Repository 层提供的接口, 并不关心其底层依赖的是哪种具体的数据库. 当需要替换数据库的时候, 比如从 MySQL 到 Oracle, 从 Oracle 到 Redis, 只需要改动 Repository 层的代码, Service 层的代码完全不需要修改.
除此之外, Controller, Service, Repository 三层代码的稳定程度不同, 引起变化的原因不同, 所以分成三层来组织代码, 能有效地隔离变化. 比如, Repository 层基于数据库表, 而数据库表改动的可能性很小, 所以 Repository 层的代码最稳定, 而 Controller 层提供适配给外部使用的接口, 代码经常会变动. 分层之后, Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层.
分层能起到隔离关注点的作用
Repository 层只关注数据的读写. Service 层只关注业务逻辑, 不关注数据的来源. Controller 层只关注与外界打交道, 数据校验, 封装, 格式转换, 并不关心业务逻辑. 三层之间的关注点不同, 分层之后, 职责分明, 更加符合单一职责原则, 代码的内聚性更好.
分层能提高代码的可测试性
后面讲单元测试的时候会讲到, 单元测试不依赖不可控的外部组件, 比如数据库. 分层之后, Repsitory 层的代码通过依赖注入的方式供 Service 层使用, 当要测试包含核心业务逻辑的 Service 层代码的时候, 可以用 mock 的数据源替代真实的数据库, 注入到 Service 层代码中. 代码的可测试性和单元测试后面会讲到, 这里稍微了解即可.
分层能应对系统的复杂性
所有的代码都放到一个类中, 那这个类的代码就会因为需求的迭代而无限膨胀. 当一个类或一个函数的代码过多之后, 可读性, 可维护性就会变差. 那我们就要想办法拆分. 拆分有垂直和水平两个方向. 水平方向基于业务来做拆分, 就是模块化; 垂直方向基于流程来做拆分, 就是这里说的分层.
还是那句话, 不管是分层, 模块化, 还是 OOP, DDD, 以及各种设计模式, 原则和思想, 都是为了应对复杂系统, 应对系统的复杂性. 对于简单系统来说, 其实是发挥不了作用的, 就是俗话说的"杀鸡焉用牛刀".
# 3.BO,VO,Entity存在的意义是什么?
前面提到, 针对 Controller, Service, Repository 三层, 每层都会定义相应的数据对象, 它们分别是 VO(View Object), BO(Business Object), Entity, 例如 UserVo, UserBo, UserEntity. 在实际的开发中, VO, BO, Entity 可能存在大量的重复字段, 甚至三者包含的字段完全一样. 在开发的过程中, 经常需要重复定义三个几乎一样的类, 显然是一种重复劳动.
**相对于每层定义各自的数据对象来说, 是不是定义一个公共的数据对象更好些呢? **
实际上, 我更加推荐每层都定义各自的数据对象这种设计思路, 主要有以下 3 个方面的原因.
- VO, BO, Entity 并非完全一样. 比如, 可以在 UserEntity, UserBo 中定义 Password 字段, 但显然不能在 UserVo 中定义 Password 字段, 否则就会将用户的密码暴露出去.
- VO, BO, Entity 三个类虽然代码重复, 但功能语义不重复, 从职责上讲是不一样的. 所以, 也并不能算违背 DRY 原则. 在前面讲到 DRY 原则的时候, 针对这种情况, 如果合并为同一个类, 那也会存在后期因为需求的变化而需要再拆分的问题.
- 为了尽量减少每层之间的耦合, 把职责边界划分明确, 每层都会维护自己的数据对象, 层与层之间通过接口交互. 数据从下一层传递到上一层的时候, 将下一层的数据对象转化成上一层的数据对象, 再继续处理. 虽然这样的设计稍微有些繁琐, 每层都需要定义各自的数据对象, 需要做数据对象之间的转化, 但是分层清晰. 对于非常大的项目来说, 结构清晰是第一位的!
**既然 VO, BO, Entity 不能合并, 那如何解决代码重复的问题呢? **
从设计的角度来说, VO, BO, Entity 的设计思路并不违反 DRY 原则, 为了分层清晰, 减少耦合, 多维护几个类的成本也并不是不能接受的. 但是, 如果你真的有代码洁癖, 对于代码重复的问题, 也有一些办法来解决.
前面讲到, 继承可以解决代码重复问题. 可以将公共的字段定义在父类中, 让 VO, BO, Entity 都继承这个父类, 各自只定义特有的字段. 因为这里的继承层次很浅, 也不复杂, 所以使用继承并不会影响代码的可读性和可维护性. 后期如果因为业务的需要, 有些字段需要从父类移动到子类, 或者从子类提取到父类, 代码改起来也并不复杂.
前面在讲 "多用组合, 少用继承" 设计思想的时候提到, 组合也可以解决代码重复的问题, 所以这里还可以将公共的字段抽取到公共的类中, VO, BO, Entity 通过组合关系来复用这个类的代码.
**代码重复问题解决了, 那不同分层之间的数据对象该如何互相转化呢? **
当下一层的数据通过接口调用传递到上一层之后, 需要将它转化成上一层对应的数据对象类型. 比如, Service 层从 Repository 层获取的 Entity 之后, 将其转化成 BO, 再继续业务逻辑的处理. 所以整个开发的过程会涉及 "Entity 到 BO" 和 "BO 到 VO" 这两种转化. 最简单的转化方式是手动复制. 自己写代码在两个对象之间, 一个字段一个字段的赋值. 但这样的做法显然是没有技术含量的低级劳动. Java 中提供了多种数据对象转化工具, 比如 BeanUtils, Dozer 等, 可以大大简化繁琐的对象转化工作. 如果你是用其他编程语言来做开发, 也可以借鉴 Java 这些工具类的设计思路, 自己在项目中实现对象转化工具类.
**VO, BO, Entity 都是基于贫血模型的, 而且为了兼容框架或开发库(比如 MyBatis, Dozer, BeanUtils), 还需要定义每个字段的 set 方法. 这些都违背 OOP 的封装特性, 会导致数据被随意修改. 那到底该怎么办好呢? **
前面也提到过, Entity 和 VO 的生命周期是有限的, 都仅限在本层范围内. 而对应的 Repository 层和 Controller 层也都不包含太多业务逻辑, 所以也不会有太多代码随意修改数据, 即便设计成贫血, 定义每个字段的 set 方法, 相对来说也是安全的.
不过, Service 层包含比较多的业务逻辑代码, 所以 BO 就存在被任意修改的风险了. 但是, 设计的问题本身就没有最优解, 只有权衡. 为了使用方便, 只能做一些妥协, 放弃 BO 的封装特性, 由程序员自己来负责这些数据对象的不被错误使用.
# 4.总结用到的设计原则和思想
前面提到, 很多人做业务开发, 总感觉就是 CRUD, 翻译代码, 根本用不到设计原则, 思想和模式. 实际上, 只是你没有发现而已. 现在就给你罗列一下, 今天讲解的内容中, 都用到了哪些设计原则, 思想和模式.

重点回顾
**1.为什么要分 MVC 三层开发? **
对于这个问题, 我总结了以下 5 点原因.
- 分层能起到代码复用的作用
- 分层能起到隔离变化的作用
- 分层能起到隔离关注点的作用
- 分层能提高代码的可测试性
- 分层能应对系统的复杂性
**2.BO, VO, Entity 存在的意义是什么? **
从设计的角度来说, VO, BO, Entity 的设计思路并不违反 DRY 原则, 为了分层清晰, 减少耦合, 多维护几个类的成本也并不是不能接受的. 但是, 如果你真的有代码洁癖, 对于代码重复的问题, 可以通过继承或者组合来解决.
如何进行数据对象之间的转化? 最简单的方式就是手动复制. 当然也可以使用 Java 中提供了数据对象转化工具, 比如 BeanUtils, Dozer 等, 可以大大简化繁琐的对象转化工作.
尽管 VO, BO, Entity 的设计违背 OOP 的封装特性, 有被随意修改的风险. 但 Entity 和 VO 的生命周期是有限的, 都仅限在本层范围内, 相对来说是安全的. Service 层包含比较多的业务逻辑代码, 所以 BO 就存在被任意修改的风险了. 为了使用方便, 只能做一些妥协, 放弃 BO 的封装特性, 由程序员自己来负责这些数据对象的不被错误使用.
# 25-实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?
接下来的两节, 再结合一个支持各种统计规则的性能计数器项目, 学习针对一个非业务的通用框架开发, 如何来做需求分析, 设计和实现, 同时学习如何灵活应用各种设计原则.
# 1.项目背景
我们希望设计开发一个小的框架, 能够获取接口调用的各种统计信息, 比如, 响应时间的最大值(max), 最小值(min), 平均值(avg), 百分位值(percentile), 接口调用次数(count), 频率(tps) 等, 并且支持将统计结果以各种显示格式(比如: JSON 格式, 网页格式, 自定义显示格式等)输出到各种终端(Console 命令行, HTTP 网页, Email, 日志文件, 自定义输出终端等), 以方便查看.
# 2.需求分析
性能计数器作为一个跟业务无关的功能, 完全可以把它开发成一个独立的框架或者类库, 集成到很多业务系统中. 而作为可被复用的框架, 除了功能性需求之外, 非功能性需求也非常重要. 所以接下来从这两个方面来做需求分析.
# (1)功能性需求分析
相对于一大长串的文字描述, 人脑更容易理解短的, 罗列的比较规整, 分门别类的列表信息. 显然, 刚才那段需求描述不符合这个规律. 我们需要把它拆解成一个一个的"干条条". 拆解之后如下, 是不是看起来更加清晰, 有条理?
- 接口统计信息: 包括接口响应时间的统计信息, 以及接口调用次数的统计信息等.
- 统计信息的类型: max, min, avg, percentile, count, tps 等.
- 统计信息显示格式: Json, Html, 自定义显示格式.
- 统计信息显示终端: Console, Email, HTTP 网页, 日志, 自定义显示终端.
除此之外, 还可以借助设计产品的时候, 经常用到的线框图, 把最终数据的显示样式画出来, 会更加一目了然. 具体的线框图如下所示:

实际上, 从线框图中, 还能挖掘出了下面几个隐藏的需求.
- 统计触发方式: 包括主动和被动两种. 主动表示以一定的频率定时统计数据, 并主动推送到显示终端, 比如邮件推送. 被动表示用户触发统计, 比如用户在网页中选择要统计的时间区间, 触发统计, 并将结果显示给用户.
- 统计时间区间: 框架需要支持自定义统计时间区间, 比如统计最近 10 分钟的某接口的 tps, 访问次数, 或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值, 最小值, 平均值等.
- 统计时间间隔: 对于主动触发统计, 还要支持指定统计时间间隔, 也就是多久触发一次统计显示. 比如, 每间隔 10s 统计一次接口信息并显示到命令行中, 每间隔 24 小时发送一封统计信息邮件.
# (2)非功能性需求分析
对于这样一个通用的框架的开发, 还需要考虑很多非功能性的需求. 这里总结了以下几个比较重要的方面.
易用性
易用性听起来更像是一个评判产品的标准. 没错, 在开发这样一个技术框架的时候, 也要有产品意识. 框架是否易集成, 易插拔, 跟业务代码是否松耦合, 提供的接口是否够灵活等等, 都是应该花心思去思考和设计的. 有的时候, 文档写得好坏甚至都有可能决定一个框架是否受欢迎.
性能
对于需要集成到业务系统的框架来说, 不希望框架本身的代码执行效率, 对业务系统有太多性能上的影响. 对于性能计数器这个框架来说, 一方面, 希望它是低延迟的, 也就是说, 统计代码不影响或很少影响接口本身的响应时间; 另一方面, 希望框架本身对内存的消耗不能太大.
扩展性
这里说的扩展性跟之前讲到的代码的扩展性有点类似, 都是指在不修改或尽量少修改代码的情况下添加新的功能. 但是这两者也有区别. 之前讲到的扩展是从框架代码开发者的角度来说的. 这里所说的扩展是从框架使用者的角度来说的, 特指使用者可以在不修改框架源码, 甚至不拿到框架源码的情况下, 为框架扩展新的功能. 这就有点类似给框架开发插件. 关于这一点, 举一个例子来解释一下.
feign 是一个 HTTP 客户端框架, 可以在不修改框架源码的情况下, 用如下方式来扩展自己的编解码方式, 日志, 拦截器等.
Feign feign = Feign.builder()
.logger(new CustomizedLogger())
.encoder(new FormEncoder(new JacksonEncoder()))
.decoder(new JacksonDecoder())
.errorDecoder(new ResponseErrorDecoder())
.requestInterceptor(new RequestHeadersInterceptor()).build();
public class RequestHeadersInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
template.header("appId", "...");
template.header("version", "...");
template.header("timestamp", "...");
template.header("token", "...");
template.header("idempotent-token", "...");
template.header("sequence-id", "...");
}
public class CustomizedLogger extends feign.Logger {
//...
}
public class ResponseErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
//...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
容错性
容错性这一点也非常重要. 对于性能计数器框架来说, 不能因为框架本身的异常导致接口请求出错. 所以, 要对框架可能存在的各种异常情况都考虑全面, 对外暴露的接口抛出的所有运行时, 非运行时异常都进行捕获处理.
通用性
为了提高框架的复用性, 能够灵活应用到各种场景中. 框架在设计的时候, 要尽可能通用. 要多去思考一下, 除了接口统计这样一个需求, 还可以适用到其他哪些场景中, 比如是否还可以处理其他事件的统计信息, 比如 SQL 请求时间的统计信息, 业务统计信息(比如支付成功率)等.
# 3.框架设计
前面讲了需求分析, 现在来看如何针对需求做框架设计.
对于稍微复杂系统的开发, 很多人觉得不知从何开始. 我个人喜欢借鉴 TDD(测试驱动开发)和 Prototype(最小原型) 的思想, 先聚焦于一个简单的应用场景, 基于此设计实现一个简单的原型. 尽管这个最小原型系统在功能和非功能特性上都不完善, 但它能够看得见, 摸得着, 比较具体, 不抽象, 能够很有效地帮助我缕清更复杂的设计思路, 是迭代设计的基础.
这就好比做算法题目. 当想要一下子就想出一个最优解法时, 可以先写几组测试数据, 找找规律, 再先想一个最简单的算法去解决它. 虽然这个最简单的算法在时间, 空间复杂度上可能都不令人满意, 但是可以基于此来做优化, 这样思路就会更加顺畅.
对于性能计数器这个框架的开发来说, 可以先聚焦于一个非常具体, 简单的应用场景, 比如统计用户注册, 登录这两个接口的响应时间的最大值和平均值, 接口调用次数, 并且将统计结果以 JSON 的格式输出到命令行中. 现在这个需求简单, 具体, 明确, 设计实现起来难度降低了很多.
先给出应用场景的代码. 具体如下所示:
// 应用场景: 统计下面两个接口 (注册和登录)的响应时间和访问次数
public class UserController {
public void register(UserVo user) {
//...
}
public UserVo login(String telephone, String password) {
//...
}
}
2
3
4
5
6
7
8
9
10
要输出接口的响应时间的最大值, 平均值和接口调用次数, 首先要采集每次接口请求的响应时间, 并且存储起来, 然后按照某个时间间隔做聚合统计, 最后才是将结果输出. 在原型系统的代码实现中, 可以把所有代码都塞到一个类中, 暂时不用考虑任何代码质量, 线程安全, 性能, 扩展性等等问题, 怎么简单怎么来就行.
最小原型的代码实现如下所示. 其中, recordResponseTime() 和 recordTimestamp() 两个函数分别用来记录接口请求的响应时间和访问时间. startRepeatedReport() 函数以指定的频率统计数据并输出结果.
public class Metrics {
// Map 的 key 是接口名称, value 对应接口请求的响应时间或时间戳;
private Map<String, List<Double>> responseTimes = new HashMap<>();
private Map<String, List<Double>> timestamps = new HashMap<>();
private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
public void recordResponseTime(String apiName, double responseTime) {
responseTimes.putIfAbsent(apiName, new ArrayList<>());
responseTimes.get(apiName).add(responseTime);
}
public void recordTimestamp(String apiName, double timestamp) {
timestamps.putIfAbsent(apiName, new ArrayList<>());
timestamps.get(apiName).add(timestamp);
}
public void startRepeatedReport(long period, TimeUnit unit){
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Gson gson = new Gson();
Map<String, Map<String, Double>> stats = new HashMap<>();
for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet()) {
String apiName = entry.getKey();
List<Double> apiRespTimes = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("max", max(apiRespTimes));
stats.get(apiName).put("avg", avg(apiRespTimes));
}
for (Map.Entry<String, List<Double>> entry : timestamps.entrySet()) {
String apiName = entry.getKey();
List<Double> apiTimestamps = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("count", (double)apiTimestamps.size());
}
System.out.println(gson.toJson(stats));
}
}, 0, period, unit);
}
private double max(List<Double> dataset) {// 省略代码实现
}
private double avg(List<Double> dataset) {// 省略代码实现
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
我们通过不到 50 行代码就实现了最小原型. 接下来再来看, 如何用它来统计注册, 登录接口的响应时间和访问次数. 具体的代码如下所示:
// 应用场景: 统计下面两个接口 (注册和登录)的响应时间和访问次数
public class UserController {
private Metrics metrics = new Metrics();
public UserController() {
metrics.startRepeatedReport(60, TimeUnit.SECONDS);
}
public void register(UserVo user) {
long startTimestamp = System.currentTimeMillis();
metrics.recordTimestamp("regsiter", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("register", respTime);
}
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
metrics.recordTimestamp("login", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("login", respTime);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
最小原型的代码实现虽然简陋, 但它却帮我们将思路理顺了很多, 现在就基于它做最终的框架设计. 下面是针对性能计数器框架画的一个粗略的系统设计图. 图可以非常直观地体现设计思想, 并且能有效地帮助我们释放更多的脑空间, 来思考其他细节问题.

如图所示, 把整个框架分为四个模块: 数据采集, 存储, 聚合统计, 显示. 每个模块负责的工作简单罗列如下.
- 数据采集: 负责打点采集原始数据, 包括记录每次接口请求的响应时间和请求时间. 数据采集过程要高度容错, 不能影响到接口本身的可用性. 除此之外, 因为这部分功能是暴露给框架的使用者的, 所以在设计数据采集 API 的时候, 也要尽量考虑其易用性.
- 存储: 负责将采集的原始数据保存下来, 以便后面做聚合统计. 数据的存储方式有多种, 比如: Redis, MySQL, HBase, 日志, 文件, 内存等. 数据存储比较耗时, 为了尽量地减少对接口性能(比如响应时间)的影响, 采集和存储的过程异步完成.
- 聚合统计: 负责将原始数据聚合为统计数据, 比如: max, min, avg, pencentile, count, tps 等. 为了支持更多的聚合统计规则, 代码希望尽可能灵活, 可扩展.
- 显示: 负责将统计数据以某种格式显示到终端, 比如: 输出到命令行, 邮件, 网页, 自定义显示终端等.
前面讲到面向对象分析, 设计和实现的时候, 讲到设计阶段最终输出的是类的设计, 同时也讲到, 软件设计开发是一个迭代的过程, 分析, 设计和实现这三个阶段的界限划分并不明显. 所以, 今天只给出了比较粗略的模块划分, 至于更加详细的设计, 下一节中跟实现一块来讲解.
重点回顾
对于非业务通用框架的开发, 在做需求分析的时候, 除了功能性需求分析之外, 还需要考虑框架的非功能性需求. 比如, 框架的易用性, 性能, 扩展性, 容错性, 通用性等.
对于复杂框架的设计, 很多人往往觉得无从下手. 今天分享了几个小技巧, 其中包括: 画产品线框图, 聚焦简单应用场景, 设计实现最小原型, 画系统设计图等. 这些方法的目的都是为了让问题简化, 具体, 明确, 提供一个迭代设计开发的基础, 逐步推进.
实际上, 不仅仅是软件设计开发, 不管做任何事情, 如果总是等到所有的东西都想好了再开始, 那这件事情可能永远都开始不了. 有句老话讲: 万事开头难, 所以, 先迈出第一步很重要.
# 26-实战二(下):如何实现一个支持各种统计规则的性能计数器?
上一节对计数器框架做了需求分析和粗略的模块划分. 本节利用面向对象设计, 实现方法, 并结合之前学过的设计思想, 设计原则来看一下, 如何编写灵活, 可扩展的, 高质量的代码实现.
# 1.小步快跑,逐步迭代
在上一节将整个框架分为数据采集, 存储, 聚合统计, 显示这四个模块. 除此之外, 关于统计触发方式(主动推送, 被动触发统计), 统计时间区间(统计哪一个时间段内的数据), 统计时间间隔(对于主动推送方法, 多久统计推送一次)也做了简单的设计.
我试图去实现上面罗列的所有功能需求, 希望写出一个完美的框架, 发现这是件挺烧脑的事情, 在写代码的过程中, 一直有种"脑子不够使"的感觉. 我这个有十多年工作经验的人尚且如此, 对于没有太多经验的开发者来说, 想一下子把所有需求都实现出来, 更是一件非常有挑战的事情. 一旦无法顺利完成, 你可能就会有很强的挫败感, 就会陷入自我否定的情绪中.
不过, 即便你有能力将所有需求都实现, 可能也要花费很大的设计精力和开发时间, 迟迟没有产出, 你的 leader 会因此产生很强的不可控感. 对于现在的互联网项目来说, 小步快跑, 逐步迭代是一种更好的开发模式. 所以, 应该分多个版本逐步完善这个框架. 第一个版本可以先实现一些基本功能, 对于更高级, 更复杂的功能, 以及非功能性需求不做过高的要求, 在后续的版本中继续迭代优化.
针对这个框架的开发, 在 v1.0 版本中, 暂时只实现下面这些功能. 剩下的功能留在 v2.0, v3.0 版本, 也就是后面再来讲解.
- 数据采集: 负责打点采集原始数据, 包括记录每次接口请求的响应时间和请求时间.
- 存储: 负责将采集的原始数据保存下来, 以便之后做聚合统计. 数据的存储方式有很多种, 暂时只支持 Redis 这一种存储方式, 并且, 采集与存储两个过程同步执行.
- 聚合统计: 负责将原始数据聚合为统计数据, 包括响应时间的最大值, 最小值, 平均值, 99.9 百分位值, 99 百分位值, 以及接口请求的次数和 tps.
- 显示: 负责将统计数据以某种格式显示到终端, 暂时只支持主动推送给命令行和邮件. 命令行间隔 n 秒统计显示上 m 秒的数据(比如, 间隔 60s 统计上 60s 的数据). 邮件每日统计上日的数据.
现在这个版本的需求比之前的要更加具体, 简单了, 实现起来也更加容易一些. 实际上, 学会结合具体的需求, 做合理的预判, 假设, 取舍, 规划版本的迭代设计开发, 也是一个资深工程师必须要具备的能力.
# 2.面向对象设计与实现
前面把面向对象设计与实现分开来讲解, 界限划分比较明显. 在实际的软件开发中, 这两个过程往往是交叉进行的. 一般是先有一个粗糙的设计, 然后着手实现, 实现的过程发现问题, 再回过头来补充修改设计. 所以, 对于这个框架的开发来说, 把设计和实现放到一块来讲解.
回顾上一节中的最小原型的实现, 所有的代码都耦合在一个类中, 这显然是不合理的. 接下来就按照之前讲的面向对象设计的几个步骤, 来重新划分, 设计类.
# (1)划分职责进而识别出有哪些类
根据需求描述, 先大致识别出下面几个接口或类. 这一步不难, 完全就是翻译需求.
- MetricsCollector 类负责提供 API, 来采集接口请求的原始数据. 可以为 MetricsCollector 抽象出一个接口, 但这并不是必须的, 因为暂时只能想到一个 MetricsCollector 的实现方式.
- MetricsStorage 接口负责原始数据存储, RedisMetricsStorage 类实现 MetricsStorage 接口. 这样做是为了今后灵活地扩展新的存储方法, 比如用 HBase 来存储.
- Aggregator 类负责根据原始数据计算统计数据.
- ConsoleReporter 类, EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件. 至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类, 或者抽象出一个公共的接口, 暂时还不能确定.
# (2)定义类及类与类之间的关系
接下来就是定义类及属性和方法, 定义类与类之间的关系. 这两步没法分得很开, 所以将它们合在一起来讲解.
大致地识别出几个核心的类之后, 我的习惯性做法是, 先在 IDE 中创建好这几个类, 然后开始试着定义它们的属性和方法. 在设计类, 类与类之间交互的时候, 我会不断地用之前学过的设计原则和思想来审视设计是否合理, 比如, 是否满足单一职责原则, 开闭原则, 依赖注入, KISS 原则, DRY 原则, 迪米特法则, 是否符合基于接口而非实现编程思想, 代码是否高内聚, 低耦合, 是否可以抽象出可复用代码等等.
MetricsCollector 类的定义非常简单, 具体代码如下所示. 对比上一节中最小原型的代码, MetricsCollector 通过引入 RequestInfo 类来封装原始数据信息, 用一个采集函数代替了之前的两个函数.
public class MetricsCollector {
private MetricsStorage metricsStorage;// 基于接口而非实现编程
// 依赖注入
public MetricsCollector(MetricsStorage metricsStorage) {
this.metricsStorage = metricsStorage;
}
// 用一个函数代替了最小原型中的两个函数
public void recordRequest(RequestInfo requestInfo) {
if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
return;
}
metricsStorage.saveRequestInfo(requestInfo);
}
}
public class RequestInfo {
private String apiName;
private double responseTime;
private long timestamp;
//... 省略 constructor/getter/setter 方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
MetricsStorage 类和 RedisMetricsStorage 类的属性和方法也比较明确. 具体的代码实现如下所示. 注意, 一次性取太长时间区间的数据, 可能会导致拉取太多的数据到内存中, 有可能会撑爆内存. 对于 Java 来说, 就有可能会触发 OOM(Out Of Memory). 而且即便不出现 OOM, 内存还够用, 但也会因为内存吃紧, 导致频繁的 Full GC, 进而导致系统接口请求处理变慢, 甚至超时. 这个问题会在后面解答.
public interface MetricsStorage {
void saveRequestInfo(RequestInfo requestInfo);
List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis);
Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis);
}
public class RedisMetricsStorage implements MetricsStorage {
//... 省略属性和构造函数等...
@Override
public void saveRequestInfo(RequestInfo requestInfo) {
//...
}
@Override
public List<RequestInfo> getRequestInfos(String apiName, long startTimestamp, long endTimestamp) {
//...
}
@Override
public Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, long endTimestamp) {
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
MetricsCollector 类和 MetricsStorage 类的设计思路比较简单, 不同的人给出的设计结果应该大差不差. 但是, 统计和显示这两个功能就不一样了, 可以有多种设计思路. 实际上, 如果把统计显示所要完成的功能逻辑细分一下的话, 主要包含下面 4 点:
- 根据给定的时间区间, 从数据库中拉取数据;
- 根据原始数据, 计算得到统计数据;
- 将统计数据显示到终端(命令行或邮件);
- 定时触发以上 3 个过程的执行.
实际上, 如果用一句话总结一下的话, 面向对象设计和实现要做的事情, 就是把合适的代码放到合适的类中. 所以, 现在要做的工作就是, 把以上的 4 个功能逻辑划分到几个类中. 划分的方法有很多种, 比如可以把前两个逻辑放到一个类中, 第 3 个逻辑放到另外一个类中, 第 4 个逻辑作为上帝类(God Class)组合前面两个类来触发前 3 个逻辑的执行. 当然也可以把第 2 个逻辑单独放到一个类中, 第 1, 3, 4 都放到另外一个类中.
至于到底选择哪种排列组合方式, 判定的标准是, 让代码尽量地满足低耦合, 高内聚, 单一职责, 对扩展开放对修改关闭等之前讲到的各种设计原则和思想, 尽量地让设计满足代码易复用, 易读, 易扩展, 易维护.
这里暂时选择把第 1, 3, 4 逻辑放到 ConsoleReporter 或 EmailReporter 类中, 把第 2 个逻辑放到 Aggregator 类中. 其中, Aggregator 类负责的逻辑比较简单, 可以把它设计成只包含静态方法的工具类. 具体的代码实现如下所示:
public class Aggregator {
public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
double maxRespTime = Double.MIN_VALUE;
double minRespTime = Double.MAX_VALUE;
double avgRespTime = -1;
double p999RespTime = -1;
double p99RespTime = -1;
double sumRespTime = 0;
long count = 0;
for (RequestInfo requestInfo : requestInfos) {
++count;
double respTime = requestInfo.getResponseTime();
if (maxRespTime < respTime) {
maxRespTime = respTime;
}
if (minRespTime > respTime) {
minRespTime = respTime;
}
sumRespTime += respTime;
}
if (count != 0) {
avgRespTime = sumRespTime / count;
}
long tps = (long)(count / durationInMillis * 1000);
Collections.sort(requestInfos, new Comparator<RequestInfo>() {
@Override
public int compare(RequestInfo o1, RequestInfo o2) {
double diff = o1.getResponseTime() - o2.getResponseTime();
if (diff < 0.0) {
return -1;
} else if (diff > 0.0) {
return 1;
} else {
return 0;
}
}
});
int idx999 = (int)(count * 0.999);
int idx99 = (int)(count * 0.99);
if (count != 0) {
p999RespTime = requestInfos.get(idx999).getResponseTime();
p99RespTime = requestInfos.get(idx99).getResponseTime();
}
RequestStat requestStat = new RequestStat();
requestStat.setMaxResponseTime(maxRespTime);
requestStat.setMinResponseTime(minRespTime);
requestStat.setAvgResponseTime(avgRespTime);
requestStat.setP999ResponseTime(p999RespTime);
requestStat.setP99ResponseTime(p99RespTime);
requestStat.setCount(count);
requestStat.setTps(tps);
return requestStat;
}
}
public class RequestStat {
private double maxResponseTime;
private double minResponseTime;
private double avgResponseTime;
private double p999ResponseTime;
private double p99ResponseTime;
private long count;
private long tps;
//... 省略 getter/setter 方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
ConsoleReporter 类相当于一个上帝类, 定时根据给定的时间区间, 从数据库中取出数据, 借助 Aggregator 类完成统计工作, 并将统计结果输出到命令行. 具体的代码实现如下所示:
public class ConsoleReporter {
private MetricsStorage metricsStorage;
private ScheduledExecutorService executor;
public ConsoleReporter(MetricsStorage metricsStorage) {
this.metricsStorage = metricsStorage;
this.executor = Executors.newSingleThreadScheduledExecutor();
}
// 第 4 个代码逻辑: 定时触发第 1, 2, 3 代码逻辑的执行;
public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
// 第 1 个代码逻辑: 根据给定的时间区间, 从数据库中拉取数据;
long durationInMillis = durationInSeconds * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> stats = new HashMap<>();
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
// 第 2 个代码逻辑: 根据原始数据, 计算得到统计数据;
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
stats.put(apiName, requestStat);
}
// 第 3 个代码逻辑: 将统计数据显示到终端(命令行或邮件);
System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
Gson gson = new Gson();
System.out.println(gson.toJson(stats));
}
}, 0, periodInSeconds, TimeUnit.SECONDS);
}
}
public class EmailReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private EmailSender emailSender;
private List<String> toAddresses = new ArrayList<>();
public EmailReporter(MetricsStorage metricsStorage) {
this(metricsStorage, new EmailSender(/* 省略参数 */));
}
public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
this.metricsStorage = metricsStorage;
this.emailSender = emailSender;
}
public void addToAddress(String address) {
toAddresses.add(address);
}
public void startDailyReport() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date firstTime = calendar.getTime();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> stats = new HashMap<>();
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
stats.put(apiName, requestStat);
}
// TODO: 格式化为 html 格式, 并且发送邮件
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# (3)将类组装起来并提供执行入口
因为这个框架稍微有些特殊, 有两个执行入口: 一个是 MetricsCollector 类, 提供了一组 API 来采集原始数据; 另一个是 ConsoleReporter 类和 EmailReporter 类, 用来触发统计显示. 框架具体的使用方式如下所示:
public class Demo {
public static void main(String[] args) {
MetricsStorage storage = new RedisMetricsStorage();
ConsoleReporter consoleReporter = new ConsoleReporter(storage);
consoleReporter.startRepeatedReport(60, 60);
EmailReporter emailReporter = new EmailReporter(storage);
emailReporter.addToAddress("wangzheng@xzg.com");
emailReporter.startDailyReport();
MetricsCollector collector = new MetricsCollector(storage);
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("register", 323, 12334));
collector.recordRequest(new RequestInfo("login", 23, 12434));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 3.Review设计与实现
前面讲到了 SOLID, KISS, DRY, YAGNI, LOD 等设计原则, 基于接口而非实现编程, 多用组合少用继承, 高内聚低耦合等设计思想. 现在就来看下, 上面的代码实现是否符合这些设计原则和思想.
MetricsCollector
MetricsCollector 负责采集和存储数据, 职责相对来说还算比较单一. 它基于接口而非实现编程, 通过依赖注入的方式来传递 MetricsStorage 对象, 可以在不需要修改代码的情况下, 灵活地替换不同的存储方式, 满足开闭原则.
MetricsStorage, RedisMetricsStorage
MetricsStorage 和 RedisMetricsStorage 的设计比较简单. 当需要实现新的存储方式的时候, 只需要实现 MetricsStorage 接口即可. 因为所有用到 MetricsStorage 和 RedisMetricsStorage 的地方, 都是基于相同的接口函数来编程的, 所以除了在组装类的地方有所改动(从 RedisMetricsStorage 改为新的存储实现类), 其他接口函数调用的地方都不需要改动, 满足开闭原则.
Aggregator
Aggregator 类是一个工具类, 里面只有一个静态函数, 有 50 行左右的代码量, 负责各种统计数据的计算. 当需要扩展新的统计功能的时候, 需要修改 aggregate() 函数代码, 并且一旦越来越多的统计功能添加进来之后, 这个函数的代码量会持续增加, 可读性, 可维护性就变差了. 所以, 从刚刚的分析来看, 这个类的设计可能存在职责不够单一, 不易扩展等问题, 需要在之后的版本中, 对其结构做优化.
ConsoleReporter, EmailReporter
ConsoleReporter 和 EmailReporter 中存在代码重复问题. 在这两个类中, 从数据库中取数据, 做统计的逻辑都是相同的, 可以抽取出来复用, 否则就违反了 DRY 原则. 而且整个类负责的事情比较多, 职责不是太单一. 特别是显示部分的代码, 可能会比较复杂(比如 Email 的展示方式), 最好是将显示部分的代码逻辑拆分成独立的类. 除此之外, 因为代码中涉及线程操作, 并且调用了 Aggregator 的静态函数, 所以代码的可测试性不好.
今天给出的代码实现还是有诸多问题的, 在后面的章节(第 39, 40 讲)中, 会慢慢优化, 给你展示整个设计演进的过程, 这比直接给你最终的最优方案要有意义得多! 实际上, 优秀的代码都是重构出来的, 复杂的代码都是慢慢堆砌出来的. 所以, 当你看到那些优秀而复杂的开源代码或者项目代码的时候, 也不必自惭形秽, 觉得自己写不出来. 毕竟罗马不是一天建成的, 这些优秀的代码也是靠几年的时间慢慢迭代优化出来的.
重点回顾
面向对象设计和实现要做的事情, 就是把合适的代码放到合适的类中. 至于到底选择哪种划分方法, 判定的标准是让代码尽量地满足低耦合, 高内聚, 单一职责, 对扩展开放对修改关闭等之前讲的各种设计原则和思想, 尽量地做到代码可复用, 易读, 易扩展, 易维护.
# 规范与重构
# 27-理论一:什么情况下要重构?到底重构什么?又该如何重构?
"重构" 这个词对于大部分工程师来说都不陌生. 不过, 大部分人都只是"听得多做得少", 真正进行过代码重构的人不多, 而把持续重构作为开发的一部分的人, 就更是少之又少了.
一方面, 重构代码对一个工程师能力的要求, 要比单纯写代码高得多. 重构需要你能洞察出代码存在的坏味道或者设计上的不足, 并且能合理, 熟练地利用设计思想, 原则, 模式, 编程规范等理论知识解决这些问题.
另一方面, 很多工程师对为什么要重构, 到底重构什么, 什么时候重构, 又该如何重构等相关问题理解不深, 对重构没有系统性, 全局性的认识, 面对一堆烂代码, 没有重构技巧的指导, 只能想到哪改到哪, 并不能全面地改善代码质量.
为了让你对重构有个清晰的认识, 对于这部分知识的讲解, 安排了六节课的内容, 主要包含以下几个方面:
- 对重构概括性的介绍, 包括重构的目的(why), 对象(what), 时机(when), 方法(how);
- 保证重构不出错的手段, 这里会重点讲解单元测试和代码的可测试性;
- 不同规模的重构, 重点讲解大规模高层次重构(比如系统, 模块, 代码结构, 类与类之间的交互等的重构)和小规模低层次重构(类, 函数, 变量等的重构).
现在来学习第一部分内容: 重构的目的, 对象, 时机和方法.
# 1.重构的目的:为什么要重构(why)?
软件设计大师 Martin Fowler 是这样定义重构的: "重构是一种对软件内部结构的改善, 目的是在不改变软件的可见行为的情况下, 使其更易理解, 修改成本更低."
实际上, 当讲到重构的时候, 很多书籍都会引用这个定义. 这个定义中有一个值得强调的点: "重构不改变外部的可见行为". 可以把重构理解为, 在保持功能不变的前提下, 利用设计思想, 原则, 模式, 编程规范等理论来优化代码, 修改设计上的不足, 提高代码质量.
**简单了解重构的定义之后, 重点来看一下, 为什么要进行代码重构? **
首先, 重构是时刻保证代码质量的一个极其有效的手段, 不至于让代码腐化到无可救药的地步. 项目在演进, 代码不停地在堆砌. 如果没有人为代码的质量负责任, 代码总是会往越来越混乱的方向演进. 当混乱到一定程度之后, 量变引起质变, 项目的维护成本已经高过重新开发一套新代码的成本, 想要再去重构, 已经没有人能做到了.
其次, 优秀的代码或架构不是一开始就能完全设计好的, 就像优秀的公司和产品也都是迭代出来的. 无法 100% 遇见未来的需求, 也没有足够的精力, 时间, 资源为遥远的未来买单, 所以随着系统的演进, 重构代码也是不可避免的.
最后, 重构是避免过度设计的有效手段. 在维护代码的过程中, 真正遇到问题的时候, 再对代码进行重构, 能有效避免前期投入太多时间做过度的设计, 做到有的放矢.
# 2.重构的对象:到底重构什么(what)?
根据重构的规模, 可以笼统地分为大规模高层次重构(以下简称为"大型重构")和小规模低层次的重构(以下简称为"小型重构").
大型重构指的是对顶层代码设计的重构, 包括: 系统, 模块, 代码结构, 类与类之间的关系等的重构, 重构的手段有: 分层, 模块化, 解耦, 抽象可复用组件等等. 这类重构的工具就是学习过的那些设计思想, 原则和模式. 这类重构涉及的代码改动会比较多, 影响面会比较大, 所以难度也较大, 耗时会比较长, 引入 bug 的风险也会相对比较大.
小型重构指的是对代码细节的重构, 主要是针对类, 函数, 变量等代码级别的重构, 比如规范命名, 规范注释, 消除超大类或函数, 提取重复代码等等. 小型重构更多的是利用能后面要讲到的编码规范. 这类重构要修改的地方比较集中, 比较简单, 可操作性较强, 耗时会比较短, 引入 bug 的风险相对来说也会比较小. 只需要熟练掌握各种编码规范, 就可以做到得心应手.
# 3.重构的时机:什么时候重构(when)?
搞清楚了为什么重构, 到底重构什么, 再来看一下, 什么时候重构? 是代码烂到一定程度之后才去重构吗? 当然不是. 因为当代码真的烂到出现 "开发效率低, 招了很多人, 天天加班, 出活却不多, 线上 bug 频发, 领导发飙, 中层束手无策, 工程师抱怨不断, 查找 bug 困难" 的时候, 基本上重构也无法解决问题了.
我个人比较反对, 平时不注重代码质量, 堆砌烂代码, 实在维护不了了就大刀阔斧地重构, 甚至重写的行为. 有时候项目代码太多了, 重构很难做得彻底, 最后又搞出来一个"四不像的怪物", 这就更麻烦了! 所以, 寄希望于在代码烂到一定程度之后, 集中重构解决所有问题是不现实的, 必须探索一条可持续, 可演进的方式.
所以, 我特别提倡的重构策略是持续重构. 这也是我在工作中特别喜欢干的事情. 平时没有事情的时候, 可以看看项目中有哪些写得不够好的, 可以优化的代码, 主动去重构一下. 或者, 在修改, 添加某个功能代码的时候, 也可以顺手把不符合编码规范, 不好的设计重构一下. 总之, 就像把单元测试, Code Review 作为开发的一部分, 如果能把持续重构也作为开发的一部分, 成为一种开发习惯, 对项目, 对自己都会很有好处.
尽管说重构能力很重要, 但持续重构意识更重要. 要正确地看待代码质量和重构这件事情. 技术在更新, 需求在变化, 人员在流动, 代码质量总会在下降, 代码总会存在不完美, 重构就会持续在进行. 时刻具有持续重构意识, 才能避免开发初期就过度设计, 避免代码维护的过程中质量的下降. 而那些看到别人代码有点瑕疵就一顿乱骂, 或者花尽心思去构思一个完美设计的人, 往往都是因为没有树立正确的代码质量观, 没有持续重构意识.
# 4.重构的方法:又该如何重构(how)?
前面讲到, 按照重构的规模, 重构可以笼统地分为大型重构和小型重构. 对于这两种不同规模的重构, 要区别对待.
对于大型重构来说, 因为涉及的模块, 代码会比较多, 如果项目代码质量又比较差, 耦合比较严重, 往往会牵一发而动全身, 本来觉得一天就能完成的重构, 你会发现越改越多, 越改越乱, 没一两个礼拜都搞不定. 而新的业务开发又与重构相冲突, 最后只能半途而废, revert 掉所有的改动, 很失落地又去堆砌烂代码了.
在进行大型重构的时候, 要提前做好完善的重构计划, 有条不紊地分阶段来进行. 每个阶段完成一小部分代码的重构, 然后提交, 测试, 运行, 发现没有问题之后, 再继续进行下一阶段的重构, 保证代码仓库中的代码一直处于可运行, 逻辑正确的状态. 每个阶段, 都要控制好重构影响到的代码范围, 考虑好如何兼容老的代码逻辑, 必要的时候还需要写一些兼容过渡代码. 只有这样, 才能让每一阶段的重构都不至于耗时太长(最好一天就能完成), 不至于与新的功能开发相冲突.
大规模高层次的重构一定是有组织, 有计划, 并且非常谨慎的, 需要有经验, 熟悉业务的资深同事来主导. 而小规模低层次的重构, 因为影响范围小, 改动耗时短, 所以, 只要你愿意并且有时间, 随时都可以去做. 实际上, 除了人工去发现低层次的质量问题, 还可以借助很多成熟的静态代码分析工具(比如 CheckStyle, FindBugs, PMD), 来自动发现代码中的问题, 然后针对性地进行重构优化.
对于重构这件事情, 资深的工程师, 项目 leader 要负起责任来, 没事就重构一下代码, 时刻保证代码质量处在一个良好的状态. 否则, 一旦出现"破窗效应", 一个人往里堆了一些烂代码, 之后就会有更多的人往里堆更烂的代码. 毕竟往项目里堆砌烂代码的成本太低了. 不过, 保持代码质量最好的方法还是打造一种好的技术氛围, 以此来驱动大家主动去关注代码质量, 持续重构代码.
重点回顾
**1.重构的目的:为什么重构(why)? **
对于项目来言, 重构可以保持代码质量持续处于一个可控状态, 不至于腐化到无可救药的地步. 对于个人而言, 重构非常锻炼一个人的代码能力, 并且是一件非常有成就感的事情.
**2.重构的对象:重构什么(what)? **
按照重构的规模, 可以将重构大致分为大规模高层次的重构和小规模低层次的重构. 大规模高层次重构包括对代码分层, 模块化, 解耦, 梳理类之间的交互关系, 抽象复用组件等等. 这部分工作利用的更多的是比较抽象, 比较顶层的设计思想, 原则, 模式. 小规模低层次的重构包括规范命名, 注释, 修正函数参数过多, 消除超大类, 提取重复代码等等编程细节问题, 主要是针对类, 函数级别的重构. 小规模低层次的重构更多的是利用编码规范这一理论知识.
**3.重构的时机:什么时候重构(when)? **
一定要建立持续重构意识, 把重构作为开发必不可少的部分, 融入到日常开发中, 而不是等到代码出现很大问题的时候, 再大刀阔斧地重构.
**4.重构的方法:如何重构(how)? **
大规模高层次的重构难度比较大, 需要组织, 有计划地进行, 分阶段地小步快跑, 时刻让代码处于一个可运行的状态. 而小规模低层次的重构, 因为影响范围小, 改动耗时短, 所以, 只要你愿意并且有时间, 随时随地都可以去做.
# 28-理论二:为了保证重构不出错,需要利用好单元测试
上一节对 "为什么要重构, 到底重构什么, 什么时候重构, 该如何重构", 做了概括性介绍, 强调了重构的重要性, 希望你建立持续重构意识, 将重构作为开发的一部分来执行.
很多程序员对重构这种做法还是非常认同的, 面对项目中的烂代码, 也想重构一下, 但又担心重构之后出问题, 出力不讨好. 确实, 如果要重构的代码是别的同事开发的, 你不是特别熟悉, 在没有任何保障的情况下, 重构引入 bug 的风险还是很大的.
那如何保证重构不出错呢? 你需要熟练掌握各种设计原则, 思想, 模式, 还需要对所重构的业务和代码有足够的了解. 除了这些个人能力因素之外, 最可落地执行, 最有效的保证重构不出错的手段应该就是单元测试(Unit Testing) 了. 当重构完成之后, 如果新的代码仍然能通过单元测试, 那就说明代码原有逻辑的正确性未被破坏, 原有的外部可见行为未变, 符合上一节课中对重构的定义.
那今天就来学习一下单元测试. 主要包含这样几个内容:
- 什么是单元测试?
- 为什么要写单元测试?
- 如何编写单元测试?
- 如何在团队中推行单元测试?
# 1.什么是单元测试?
单元测试由研发工程师自己来编写, 用来测试自己写的代码的正确性. 我们常常将它跟集成测试放到一块来对比. 单元测试相对于集成测试(Integration Testing)来说, 测试的粒度更小一些. 集成测试的测试对象是整个系统或者某个功能模块, 比如测试用户注册, 登录功能是否正常, 是一种端到端(end to end)的测试. 而单元测试的测试对象是类或者函数, 用来测试一个类和函数是否都按照预期的逻辑执行. 这是代码层级的测试.
这么说比较理论, 举个例子来解释一下.
public class Text {
private String content;
public Text(String content) {
this.content = content;
}
/**
* 将字符串转化成数字, 忽略字符串中的首尾空格;
* 如果字符串中包含除首尾空格之外的非数字字符, 则返回 null.
*/
public Integer toNumber() {
if (content == null || content.isEmpty()) {
return null;
}
//... 省略代码实现...
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果要测试 Text 类中的 toNumber() 函数的正确性, 应该如何编写单元测试呢?
实际上, 写单元测试本身不需要什么高深技术. 它更多的是考验程序员思维的缜密程度, 看能否设计出覆盖各种正常及异常情况的测试用例, 来保证代码在任何预期或非预期的情况下都能正确运行.
为了保证测试的全面性, 针对 toNumber() 函数, 需要设计下面这样几个测试用例.
- 如果字符串只包含数字: "123", toNumber() 函数输出对应的整数: 123.
- 如果字符串是空或者 null, toNumber() 函数返回: null.
- 如果字符串包含首尾空格: " 123", "123 ", " 123 ", toNumber() 返回对应的整数: 123.
- 如果字符串包含多个首尾空格: " 123 ", toNumber() 返回对应的整数: 123;
- 如果字符串包含非数字字符: "123a4", "123 4", toNumber() 返回 null;
当设计好测试用例之后, 剩下的就是将其翻译成代码了. 翻译成代码的过程非常简单, 我把代码贴在下面了, 你可以参考一下(这里没有使用任何测试框架).
public class Assert {
public static void assertEquals(Integer expectedValue, Integer actualValue) {
if (actualValue != expectedValue) {
String message = String.format(
"Test failed, expected: %d, actual: %d.", expectedValue, actualValue);
System.out.println(message);
} else {
System.out.println("Test succeeded.");
}
}
public static boolean assertNull(Integer actualValue) {
boolean isNull = actualValue == null;
if (isNull) {
System.out.println("Test succeeded.");
} else {
System.out.println("Test failed, the value is not null:" + actualValue);
}
return isNull;
}
}
public class TestCaseRunner {
public static void main(String[] args) {
System.out.println("Run testToNumber()");
new TextTest().testToNumber();
System.out.println("Run testToNumber_nullorEmpty()");
new TextTest().testToNumber_nullorEmpty();
System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsLeadingAndTrailingSpaces();
System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();
System.out.println("Run testToNumber_containsInvalidCharaters()");
new TextTest().testToNumber_containsInvalidCharaters();
}
}
public class TextTest {
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(123, text.toNumber());
}
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());
Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# 2.为什么要写单元测试?
单元测试除了能有效地为重构保驾护航之外, 也是保证代码质量最有效的两个手段之一(另一个是 Code Review). 我在 Google 工作的时候, 写了大量的单元测试代码, 结合我的这些开发经验, 我总结了以下几点单元测试的好处. 尽管有些听起来有点"务虚", 但如果你认真写过一些单元测试的话, 应该会很有共鸣.
单元测试能有效地帮你发现代码中的bug
能否写出 bug free 的代码, 是判断工程师编码能力的重要标准之一, 也是很多大厂面试考察的重点, 特别是像 FLAG 这样的外企. 即便像我这样代码写了十几年, 逻辑还算缜密, 清晰的人, 通过单元测试也常常会发现代码中的很多考虑不全面的地方.
在离开 Google 之后, 尽管我就职的很多公司, 其开发模式都是"快, 糙, 猛", 对单元测试根本没有要求, 但我还是坚持为自己提交的每一份代码, 都编写完善的单元测试. 得益于此, 我写的代码几乎是 bug free 的. 这也节省了我很多 fix 低级 bug 的时间, 能够有时间去做其他更有意义的事情, 我也因此在工作上赢得了很多人的认可. 可以这么说, 坚持写单元测试是保证我的代码质量的一个"杀手锏", 也是帮助我拉开与其他人差距的一个"小秘密" .
写单元测试能帮你发现代码设计上的问题
前面提到, 代码的可测试性是评判代码质量的一个重要标准. 对于一段代码, 如果很难为其编写单元测试, 或者单元测试写起来很吃力, 需要依靠单元测试框架里很高级的特性才能完成, 那往往就意味着代码设计得不够合理, 比如, 没有使用依赖注入, 大量使用静态函数, 全局变量, 代码高度耦合等.
单元测试是对集成测试的有力补充
程序运行的 bug 往往出现在一些边界条件, 异常情况下, 比如, 除数未判空, 网络超时. 而大部分异常情况都比较难在测试环境中模拟. 而单元测试可以利用下一节中讲到的 mock 的方式, 控制 mock 的对象返回需要模拟的异常, 来测试代码在这些异常情况的表现.
除此之外, 对于一些复杂系统来说, 集成测试也无法覆盖得很全面. 复杂系统往往有很多模块. 每个模块都有各种输入, 输出, 异常情况, 组合起来, 整个系统就有无数测试场景需要模拟, 无数的测试用例需要设计, 再强大的测试团队也无法穷举完备.
尽管单元测试无法完全替代集成测试, 但如果能保证每个类, 每个函数都能按照预期来执行, 底层 bug 少了, 那组装起来的整个系统, 出问题的概率也就相应减少了.
写单元测试的过程本身就是代码重构的过程
上一节提到, 要把持续重构作为开发的一部分来执行, 那写单元测试实际上就是落地执行持续重构的一个有效途径. 设计和实现代码的时候, 很难把所有的问题都想清楚. 而编写单元测试就相当于对代码的一次自我 Code Review, 在这个过程中, 可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等, 然后针对性的进行重构.
阅读单元测试能帮助你快速熟悉代码
阅读代码最有效的手段, 就是先了解它的业务背景和设计思路, 然后再去看代码, 这样代码读起来就会轻松很多. 但据我了解, 程序员都不怎么喜欢写文档和注释, 而大部分程序员写的代码又很难做到 "不言自明". 在没有文档和注释的情况下, 单元测试就起了替代性作用. 单元测试用例实际上就是用户用例, 反映了代码的功能和如何使用. 借助单元测试, 不需要深入的阅读代码, 便能知道代码实现了什么功能, 有哪些特殊情况需要考虑, 有哪些边界条件需要处理.
单元测试是 TDD 可落地执行的改进方案
测试驱动开发(Test-Driven Development, 简称 TDD)是一个经常被提及但很少被执行的开发模式. 它的核心指导思想就是测试用例先于代码编写. 不过, 要让程序员能彻底地接受和习惯这种开发模式还是挺难的, 毕竟很多程序员连单元测试都懒得写, 更何况在编写代码之前先写好测试用例了.
个人觉得, 单元测试正好是对 TDD 的一种改进方案, 先写代码, 紧接着写单元测试, 最后根据单元测试反馈出来问题, 再回过头去重构代码. 这个开发流程更加容易被接受, 更加容易落地执行, 而且又兼顾了 TDD 的优点.
# 3.如何编写单元测试?
前面举了一个给 toNumber() 函数写单元测试的例子. 根据那个例子, 可以总结得出, 写单元测试就是针对代码设计覆盖各种输入, 异常, 边界条件的测试用例, 并将这些测试用例翻译成代码的过程.
在把测试用例翻译成代码的时候, 可以利用单元测试框架, 来简化测试代码的编写. 比如, Java 中比较出名的单元测试框架有 Junit, TestNG, Spring Test 等. 这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数)等. 借助它们, 在编写测试代码的时候, 只需要关注测试用例本身的编写即可.
针对 toNumber() 函数的测试用例, 利用 Junit 单元测试框架重新实现一下, 具体代码如下所示. 可以拿它跟之前没有利用测试框架的实现方式对比一下, 看是否简化了很多呢?
import org.junit.Assert;
import org.junit.Test;
public class TextTest {
@Test
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(new Integer(123), text.toNumber());
}
@Test
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}
@Test
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}
@Test
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());
Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
对于如何使用这些单元测试框架, 大部分框架都给出了非常详细的官方文档, 你可以自行查阅. 这些东西理解和掌握起来没有太大难度, 所以这不是专栏要讲解的重点. 关于如何编写单元测试, 我更希望传达给你一些我的经验总结. 具体包括以下几点.
写单元测试真的是件很耗时的事情吗?
尽管单元测试的代码量可能是被测代码本身的 1~2 倍, 写的过程很繁琐, 但并不是很耗时. 毕竟不需要考虑太多代码设计上的问题, 测试代码实现起来也比较简单. 不同测试用例之间的代码差别可能并不是很大, 简单 copy-paste 改改就行.
对单元测试的代码质量有什么要求吗?
单元测试毕竟不会在产线上运行, 而且每个类的测试代码也比较独立, 基本不互相依赖. 所以相对于被测代码, 对单元测试代码的质量可以放低一些要求. 命名稍微有些不规范, 代码稍微有些重复, 也都是没有问题的.
单元测试只要覆盖率高就够了吗?
单元测试覆盖率是比较容易量化的指标, 常常作为单元测试写得好坏的评判标准. 有很多现成的工具专门用来做覆盖率统计, 比如, JaCoCo, Cobertura, Emma, Clover. 覆盖率的计算方式有很多种, 比较简单的是语句覆盖, 稍微高级点的有: 条件覆盖, 判定覆盖, 路径覆盖.
不管覆盖率的计算方式如何高级, 将覆盖率作为衡量单元测试质量的唯一标准是不合理的. 实际上, 更重要的是要看测试用例是否覆盖了所有可能的情况, 特别是一些 corner case. 举个简单的例子解释一下.
像下面这段代码, 只需要一个测试用例就可以做到 100% 覆盖率, 比如 cal(10.0, 2.0), 但并不代表测试足够全面了, 还需要考虑, 当除数等于 0 的情况下, 代码执行是否符合预期.
public double cal(double a, double b) {
if (b != 0) {
return a / b;
}
}
2
3
4
5
实际上, 过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率, 写很多没有必要的测试代码, 比如 get, set 方法非常简单, 没有必要测试. 从过往的经验上来讲, 一个项目的单元测试覆盖率在 60~70% 即可上线. 如果项目对代码质量要求比较高, 可以适当提高单元测试覆盖率的要求.
写单元测试需要了解代码的实现逻辑吗?
单元测试不要依赖被测试函数的具体实现逻辑, 它只关心被测函数实现了什么功能. 切不可为了追求覆盖率, 逐行阅读代码, 然后针对实现逻辑编写单元测试. 否则, 一旦对代码进行重构, 在代码的外部行为不变的情况下, 对代码的实现逻辑进行了修改, 那原本的单元测试都会运行失败, 也就起不到为重构保驾护航的作用了, 也违背了写单元测试的初衷.
如何选择单元测试框架?
写单元测试本身不需要太复杂的技术, 大部分单元测试框架都能满足. 在公司内部, 起码团队内部需要统一单元测试框架. 如果自己写的代码用已经选定的单元测试框架无法测试, 那多半是代码写得不够好, 代码的可测试性不够好. 这个时候, 要重构自己的代码, 让其更容易测试, 而不是去找另一个更加高级的单元测试框架.
# 4.单元测试为何难落地执行?
虽然很多书籍中都会讲到, 单元测试是保证重构不出错的有效手段; 也有非常多人已经认识到单元测试的重要性. 但是有多少项目有完善的, 高质量的单元测试呢? 据我了解, 真的非常非常少, 包括 BAT 这样级别公司的项目. 如果不相信的话, 你可以去看一下国内很多大厂开源的项目, 有很多项目完全没有单元测试, 还有很多项目的单元测试写得非常不完备, 仅仅测试了逻辑是否运行正确而已. 所以, 100% 落实执行单元测试是件"知易行难"的事.
写单元测试确实是一件考验耐心的活儿. 一般情况下, 单元测试的代码量要大于被测试代码量, 甚至是要多出好几倍. 很多人往往会觉得写单元测试比较繁琐, 并且没有太多挑战, 而不愿意去做. 有很多团队和项目在刚开始推行单元测试的时候, 还比较认真, 执行得比较好. 但当开发任务紧了之后, 就开始放低对单元测试的要求, 一旦出现破窗效应, 慢慢的, 大家就都不写了, 这种情况很常见.
还有一种情况就是, 由于历史遗留问题, 原来的代码都没有写单元测试, 代码已经堆砌了十几万行了, 不可能再一个一个去补单元测试. 这种情况下, 首先要保证新写的代码都要有单元测试, 其次, 每次在改动到某个类时, 如果没有单元测试就顺便补上, 不过这要求工程师们有足够强的主人翁意识(ownership), 毕竟光靠 leader 督促, 很多事情是很难执行到位的.
除此之外, 还有人觉得, 有了测试团队, 写单元测试就是浪费时间, 没有必要. 程序员这一行业本该是智力密集型的, 但现在很多公司把它搞成劳动密集型的, 包括一些大厂, 在开发过程中, 既没有单元测试, 也没有 Code Review 流程. 即便有, 做的也是差强人意. 写好代码直接提交, 然后丢给黑盒测试狠命去测, 测出问题就反馈给开发团队再修改, 测不出的问题就留在线上出了问题再修复.
在这样的开发模式下, 团队往往觉得没有必要写单元测试, 但如果把单元测试写好, 做好 Code Review, 重视起代码质量, 其实可以很大程度上减少黑盒测试的投入. 我在 Google 的时候, 很多项目几乎没有测试团队参与, 代码的正确性完全靠开发团队来保障, 线上 bug 反倒非常少.
以上是我对单元测试的认知和实践心得. 现在互联网信息如此的公开透明, 网上有很多文章可以参考, 对于程序员这个具有很强学习能力的群体来说, 学会如何写单元测试并不是一件难事, 难的是能够真正感受到它的作用, 并且打心底认可, 能 100% 落地执行. 这也是我今天的课程特别想传达给你的一点.
重点回顾
**1.什么是单元测试? **
单元测试是代码层面的测试, 由研发自己来编写, 用于测试"自己"编写的代码的逻辑的正确性. 单元测试顾名思义是测试一个"单元", 有别于集成测试, 这个"单元"一般是类或函数, 而不是模块或者系统.
**2.为什么要写单元测试? **
写单元测试的过程本身就是代码 Code Review 和重构的过程, 能有效地发现代码中的 bug 和代码设计上的问题. 除此之外, 单元测试还是对集成测试的有力补充, 还能帮助我们快速熟悉代码, 是 TDD 可落地执行的改进方案.
**3.如何编写单元测试? **
写单元测试就是针对代码设计各种测试用例, 以覆盖各种输入, 异常, 边界情况, 并将其翻译成代码. 可以利用一些测试框架来简化单元测试的编写. 除此之外, 对于单元测试, 需要建立以下正确的认知:
- 编写单元测试尽管繁琐, 但并不是太耗时;
- 我们可以稍微放低对单元测试代码质量的要求;
- 覆盖率作为衡量单元测试质量的唯一标准是不合理的;
- 单元测试不要依赖被测代码的具体实现逻辑;
- 单元测试框架无法测试, 多半是因为代码的可测试性不好.
**4.单元测试为何难落地执行? **
一方面, 写单元测试本身比较繁琐, 技术挑战不大, 很多程序员不愿意去写; 另一方面, 国内研发比较偏向"快, 糙, 猛", 容易因为开发进度紧, 导致单元测试的执行虎头蛇尾. 最后, 关键问题还是团队没有建立对单元测试正确的认识, 觉得可有可无, 单靠督促很难执行得很好.
# 29-理论三:什么是代码的可测试性?如何写出可测试性好的代码?
上一节对单元测试做了介绍, 讲了 "什么是单元测试? 为什么要编写单元测试? 如何编写单元测试? 实践中单元测试为什么难贯彻执行?" 这样几个问题.
实际上, 写单元测试并不难, 也不需要太多技巧, 相反, 写出可测试的代码反倒是件非常有挑战的事情. 所以, 今天就再来聊一聊代码的可测试性, 主要包括这样几个问题:
- 什么是代码的可测试性?
- 如何写出可测试的代码?
- 有哪些常见的不好测试的代码?
# 1.编写可测试代码案例实战
刚刚提到的这几个关于代码可测试性的问题, 我准备通过一个实战案例来讲解. 具体的被测试代码如下所示.
其中, Transaction 是经过抽象简化之后的一个电商系统的交易类, 用来记录每笔订单交易的情况. Transaction 类中的 execute() 函数负责执行转账操作, 将钱从买家的钱包转到卖家的钱包中. 真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的. 除此之外, 代码中还涉及一个分布式锁 DistributedLock 单例类, 用来避免 Transaction 并发执行, 导致用户的钱被重复转出.
public class Transaction {
private String id;
private Long buyerId;
private Long sellerId;
private Long productId;
private String orderId;
private Long createTimestamp;
private Double amount;
private STATUS status;
private String walletTransactionId;
// ...get() methods...
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
public boolean execute() throws InvalidTransactionException {
if ((buyerId <mark> null || (sellerId </mark> null || amount < 0.0) {
throw new InvalidTransactionException(...);
}
if (status == STATUS.EXECUTED) return true;
boolean isLocked = false;
try {
isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
if (!isLocked) {
return false; // 锁定未成功, 返回 false, job 兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimestamp();
if (executionInvokedTimestamp - createdTimestap > 14days) {
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
对比上一节课中的 Text 类的代码, 这段代码要复杂很多. 如果让你给这段代码编写单元测试, 你会如何来写呢? 你可以先试着思考一下, 然后再来看我下面的分析.
在 Transaction 类中, 主要逻辑集中在 execute() 函数中, 所以它是测试的重点对象. 为了尽可能全面覆盖各种正常和异常情况, 针对这个函数, 我设计了下面 6 个测试用例.
- 正常情况下, 交易执行成功, 回填用于对账(交易与钱包的交易流水)用的 walletTransactionId, 交易状态设置为 EXECUTED, 函数返回 true.
- buyerId, sellerId 为 null, amount 小于 0, 返回 InvalidTransactionException.
- 交易已过期(createTimestamp 超过 14 天), 交易状态设置为 EXPIRED, 返回 false.
- 交易已经执行了(status==EXECUTED), 不再重复执行转钱逻辑, 返回 true.
- 钱包(WalletRpcService)转钱失败, 交易状态设置为 FAILED, 函数返回 false.
- 交易正在执行着, 不会被重复执行, 函数直接返回 false.
测试用例设计完了. 现在看起来似乎一切进展顺利. 但是, 事实是, 当将测试用例落实到具体的代码实现时, 你就会发现有很多行不通的地方. 对于上面的测试用例, 第 2 个实现起来非常简单, 就不做介绍了. 重点来看其中的 1 和 3. 测试用例 4, 5, 6 跟 3 类似, 留给你自己来实现.
现在就来看测试用例 1 的代码实现. 具体如下所示:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
2
3
4
5
6
7
8
9
execute() 函数的执行依赖两个外部的服务, 一个是 RedisDistributedLock, 一个 WalletRpcService. 这就导致上面的单元测试代码存在下面几个问题.
- 如果要让这个单元测试能够运行, 需要搭建 Redis 服务和 Wallet RPC 服务. 搭建和维护的成本比较高.
- 还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后, 能够正确返回期望的结果, 然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务, 并不是我们可控的. 换句话说, 并不是我们想让它返回什么数据就返回什么.
- Transaction 的执行跟 Redis, RPC 服务通信, 需要走网络, 耗时可能会比较长, 对单元测试本身的执行性能也会有影响.
- 网络的中断, 超时, Redis, RPC 服务的不可用, 都会影响单元测试的执行.
回到单元测试的定义上来看一下. 单元测试主要是测试程序员自己编写的代码逻辑的正确性, 并非是端到端的集成测试, 它不需要测试所依赖的外部系统(分布式锁, Wallet RPC 服务)的逻辑正确性. 所以, 如果代码中依赖了外部系统或者不可控组件, 比如, 需要依赖数据库, 网络通信, 文件系统等, 那就需要将被测代码与外部系统解依赖, 而这种解依赖的方法就叫作 "mock". 所谓的 mock 就是用一个 "假" 的服务替换真正的服务. mock 的服务完全在我们的控制之下, 模拟输出我们想要的数据.
那如何来 mock 服务呢? mock 的方式主要有两种, 手动 mock 和利用框架 mock. 利用框架 mock 仅仅是为了简化代码编写, 每个框架的 mock 方式都不大一样. 这里只展示手动 mock.
通过继承 WalletRpcService 类, 并且重写其中的 moveMoney() 函数的方式来实现 mock. 具体的代码实现如下所示. 通过 mock 的方式, 可以让 moveMoney() 返回任意我们想要的数据, 完全在我们的控制范围内, 并且不需要真正进行网络通信.
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return "123bac";
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}
2
3
4
5
6
7
8
9
10
11
现在再来看, 如何用 MockWalletRpcServiceOne, MockWalletRpcServiceTwo 来替换代码中的真正的 WalletRpcService 呢?
因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的, 我们无法动态地对其进行替换. 也就是说, Transaction 类中的 execute() 方法的可测试性很差, 需要通过重构来让其变得更容易测试. 该如何重构这段代码呢?
前面讲到, 依赖注入是实现代码可测试性的最有效的手段. 可以应用依赖注入, 将 WalletRpcService 对象的创建反转给上层逻辑, 在外部创建好之后, 再注入到 Transaction 类中. 重构之后的 Transaction 类的代码如下所示:
public class Transaction {
//...
// 添加一个成员变量及其 set 方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
现在就可以在单元测试中, 非常容易地将 WalletRpcService 替换成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 了. 重构之后的代码对应的单元测试如下所示:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用 mock 对象来替代真正的 RPC 服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
WalletRpcService 的 mock 和替换问题解决了, 再来看 RedisDistributedLock. 它的 mock 和替换要复杂一些, 主要是因为 RedisDistributedLock 是一个单例类. 单例相当于一个全局变量, 我们无法 mock(无法继承和重写方法), 也无法通过依赖注入的方式来替换.
如果 RedisDistributedLock 是自己维护的, 可以自由修改, 重构, 那可以将其改为非单例的模式, 或者定义一个接口, 比如 IDistributedLock, 让 RedisDistributedLock 实现这个接口. 这样就可以像前面 WalletRpcService 的替换方式那样, 替换 RedisDistributedLock 为 MockRedisDistributedLock 了. 但如果 RedisDistributedLock 不是我们维护的, 就无权去修改这部分代码, 这个时候该怎么办呢?
可以对 transaction 上锁这部分逻辑重新封装一下. 具体代码实现如下所示:
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}
public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
public class Transaction {
//...
private TransactionLock lock;
public void setTransactionLock(TransactionLock lock) {
this.lock = lock;
}
public boolean execute() {
//...
try {
isLocked = lock.lock();
//...
} finally {
if (isLocked) {
lock.unlock();
}
}
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
针对重构过的代码, 单元测试代码修改为下面这个样子. 这样就能在单元测试代码中隔离真正的 RedisDistributedLock 分布式锁这部分逻辑了.
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) {
return true;
}
public void unlock() {}
};
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
至此, 测试用例 1 就算写好了. 通过依赖注入和 mock, 让单元测试代码不依赖任何不可控的外部服务. 你可以照着这个思路, 自己写一下测试用例 4, 5, 6.
现在再来看测试用例 3: 交易已过期(createTimestamp 超过 14 天), 交易状态设置为 EXPIRED, 返回 false. 针对这个单元测试用例, 还是先把代码写出来, 然后再来分析.
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
上面的代码看似没有任何问题. 将 transaction 的创建时间 createdTimestamp 设置为 14 天前, 也就是说, 当单元测试代码运行的时候, transaction 一定是处于过期状态. 但如果在 Transaction 类中, 并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数)呢?
你可能会说, 如果没有 createTimestamp 的 set 方法, 就重新添加一个呗! 实际上, 这违反了类的封装特性. 在 Transaction 类的设计中, createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间, 本来就不应该人为地轻易修改, 所以暴露 createTimestamp 的 set 方法, 虽然带来了灵活性, 但也带来了不可控性. 因为无法控制使用者是否会调用 set 方法重设 createTimestamp, 而重设 createTimestamp 并非我们的预期行为.
那如果没有针对 createTimestamp 的 set 方法, 那测试用例 3 又该如何实现呢? 实际上, 这是一类比较常见的问题, 就是代码中包含跟 "时间" 有关的 "未决行为" 逻辑. 一般的处理方式是将这种未决行为逻辑重新封装. 针对 Transaction 类, 只需要将交易是否过期的逻辑, 封装到 isExpired() 函数中即可, 具体的代码实现如下所示:
public class Transaction {
protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp > 14days;
}
public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
针对重构之后的代码, 测试用例 3 的代码实现如下所示:
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
通过重构, Transaction 代码的可测试性提高了. 之前罗列的所有测试用例, 现在都顺利实现了. 不过, Transaction 类的构造函数的设计还有点不妥. 为了方便查看, 我把构造函数的代码重新 copy 了一份贴到这里.
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我们发现, 构造函数中并非只包含简单赋值操作. 交易 id 的赋值逻辑稍微复杂. 最好也要测试一下, 以保证这部分逻辑的正确性. 为了方便测试, 可以把 id 赋值这部分逻辑单独抽象到一个函数中, 具体的代码实现如下所示:
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignId);
//...
}
protected void fillTransactionId(String preAssignedId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
到此为止, 我们一步一步将 Transaction 从不可测试代码重构成了测试性良好的代码. 不过, 你可能还会有疑问, Transaction 类中 isExpired() 函数就不用测试了吗? 对于 isExpired() 函数, 逻辑非常简单, 肉眼就能判定是否有 bug, 是可以不用写单元测试的.
实际上, 可测试性差的代码, 本身代码设计得也不够好, 很多地方都没有遵守之前讲到的设计原则和思想, 比如 "基于接口而非实现编程" 思想, 依赖反转原则等. 重构之后的代码, 不仅可测试性更好, 而且从代码设计的角度来说, 也遵从了经典的设计原则和思想. 这也印证了之前说过的, 代码的可测试性可以从侧面上反应代码设计是否合理. 除此之外, 在平时的开发中, 也要多思考一下, 这样编写代码, 是否容易编写单元测试, 这也有利于设计出好的代码.
# 2.其他常见的Anti-Patterns
刚刚通过一个实战案例, 讲解了如何利用依赖注入来提高代码的可测试性, 以及编写单元测试中最复杂的一部分内容: 如何通过 mock, 二次封装等方式解依赖外部服务. 现在再来总结一下, 有哪些典型的, 常见的测试性不好的代码, 也就是常说的 Anti-Patterns.
# (1)未决行为
所谓的未决行为逻辑就是, 代码的输出是随机或者说不确定的, 比如, 跟时间, 随机数有关的代码. 对于这一点, 在刚刚的实战案例中已经讲到, 可以利用刚才讲到的方法, 试着重构一下下面的代码, 并且为它编写单元测试.
# (2)全局变量
前面讲过, 全局变量是一种面向过程的编程风格, 有种种弊端. 实际上, 滥用全局变量也让编写单元测试变得困难. 举个例子来解释一下.
RangeLimiter 表示一个 [-5, 5] 的区间, position 初始在 0 位置, move() 函数负责移动 position. 其中, position 是一个静态全局变量. RangeLimiterTest 类是为其设计的单元测试, 不过, 这里面存在很大的问题, 可以先分析一下.
public class RangeLimiter {
private static AtomicInteger position = new AtomicInteger(0);
public static final int MAX_LIMIT = 5;
public static final int MIN_LIMIT = -5;
public boolean move(int delta) {
int currentPos = position.addAndGet(delta);
boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
return betweenRange;
}
}
public class RangeLimiterTest {
public void testMove_betweenRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertTrue(rangeLimiter.move(1));
assertTrue(rangeLimiter.move(3));
assertTrue(rangeLimiter.move(-5));
}
public void testMove_exceedRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertFalse(rangeLimiter.move(6));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
上面的单元测试有可能会运行失败. 假设单元测试框架顺序依次执行 testMove_betweenRange() 和 testMove_exceedRange() 两个测试用例. 在第一个测试用例执行完成之后, position 的值变成了 -1; 再执行第二个测试用例的时候, position 变成了 5, move() 函数返回 true, assertFalse 语句判定失败. 所以, 第二个测试用例运行失败.
当然, 如果 RangeLimiter 类有暴露重设(reset)position 值的函数, 可以在每次执行单元测试用例之前, 把 position 重设为 0, 这样就能解决刚刚的问题.
不过, 每个单元测试框架执行单元测试用例的方式可能是不同的. 有的是顺序执行, 有的是并发执行. 对于并发执行的情况, 即便每次都把 position 重设为 0, 也并不奏效. 如果两个测试用例并发执行, 第 16, 17, 18, 23 这四行代码可能会交叉执行, 影响到 move() 函数的执行结果.
# (3)静态方法
静态方法跟全局变量一样, 也是一种面向过程的编程思维. 在代码中调用静态方法, 有时候会导致代码不易测试. 主要原因是静态方法也很难 mock. 但这个要分情况来看. 只有在这个静态方法执行耗时太长, 依赖外部资源, 逻辑复杂, 行为未决等情况下, 才需要在单元测试中 mock 这个静态方法. 除此之外, 如果只是类似 Math.abs() 这样的简单静态方法, 并不会影响代码的可测试性, 因为本身并不需要 mock.
# (4)复杂继承
前面提到, 相比组合关系, 继承关系的代码结构更加耦合, 不灵活, 更加不易扩展, 不易维护. 实际上, 继承关系也更加难测试. 这也印证了代码的可测试性跟代码质量的相关性.
如果父类需要 mock 某个依赖对象才能进行单元测试, 那所有的子类, 子类的子类... 在编写单元测试的时候, 都要 mock 这个依赖对象. 对于层次很深(在继承关系类图中表现为纵向深度), 结构复杂(在继承关系类图中表现为横向广度)的继承关系, 越底层的子类要 mock 的对象可能就会越多, 这样就会导致, 底层子类在写单元测试的时候, 要一个一个 mock 很多依赖对象, 而且还需要查看父类代码, 去了解该如何 mock 这些依赖对象.
如果利用组合而非继承来组织类之间的关系, 类之间的结构层次比较扁平, 在编写单元测试的时候, 只需要 mock 类所组合依赖的对象即可.
# (5)高耦合代码
如果一个类职责很重, 需要依赖十几个外部对象才能完成工作, 代码高度耦合, 那在编写单元测试的时候, 可能需要 mock 这十几个依赖的对象. 不管是从代码设计的角度来说, 还是从编写单元测试的角度来说, 这都是不合理的.
重点回顾
**1.什么是代码的可测试性? **
粗略地讲, 所谓代码的可测试性, 就是针对代码编写单元测试的难易程度. 对于一段代码, 如果很难为其编写单元测试, 或者单元测试写起来很费劲, 需要依靠单元测试框架中很高级的特性, 那往往就意味着代码设计得不够合理, 代码的可测试性不好.
2.编写可测试性代码的最有效手段
依赖注入是编写可测试性代码的最有效手段. 通过依赖注入, 在编写单元测试的时候, 可以通过 mock 的方法解依赖外部服务, 这也是在编写单元测试的过程中最有技术挑战的地方.
3.常见的 Anti-Patterns
常见的测试不友好的代码有下面这 5 种:
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
# 30-理论四:如何通过封装,抽象,模块化,中间层等解耦代码?
前面讲到, 重构可以分为大规模高层重构(简称"大型重构")和小规模低层次重构(简称"小型重构"). 大型重构是对系统, 模块, 代码结构, 类之间关系等顶层代码设计进行的重构. 对于大型重构来说, 今天重点讲解最有效的一个手段, 那就是 "解耦". 解耦的目的是实现代码高内聚, 松耦合. 关于解耦, 下面分三个部分来讲解.
- "解耦"为何如此重要?
- 如何判定代码是否需要"解耦"?
- 如何给代码"解耦"?
# 1."解耦"为何如此重要?
软件设计与开发最重要的工作之一就是应对复杂性. 人处理复杂性的能力是有限的. 过于复杂的代码往往在可读性, 可维护性上都不友好. 那如何来控制代码的复杂性呢? 手段有很多, 我个人认为, 最关键的就是解耦, 保证代码松耦合, 高内聚. 如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段, 那么利用解耦的方法对代码重构, 就是保证代码不至于复杂到无法控制的有效手段.
实际上, "高内聚, 松耦合"是一个比较通用的设计思想, 不仅可以指导细粒度的类和类之间关系的设计, 还能指导粗粒度的系统, 架构, 模块的设计. 相对于编码规范, 它能够在更高层次上提高代码的可读性和可维护性. 不管是阅读代码还是修改代码, "高内聚, 松耦合"的特性可以让我们聚焦在某一模块或类中, 不需要了解太多其他模块或类的代码, 让我们的焦点不至于过于发散, 降低了阅读和修改代码的难度. 而且, 因为依赖关系简单, 耦合小, 修改代码不至于牵一发而动全身, 代码改动比较集中, 引入 bug 的风险也就减少了很多. 同时, "高内聚, 松耦合" 的代码可测试性也更加好, 容易 mock 或者很少需要 mock 外部依赖的模块或者类. 除此之外, 代码 "高内聚, 松耦合", 也就意味着, 代码结构清晰, 分层和模块化合理, 依赖关系简单, 模块或类之间的耦合小, 那代码整体的质量就不会差. 即便某个具体的类或者模块设计得不怎么合理, 代码质量不怎么高, 影响的范围是非常有限的. 可以聚焦于这个模块或者类, 做相应的小型重构. 而相对于代码结构的调整, 这种改动范围比较集中的小型重构的难度就容易多了.
# 2.代码是否需要"解耦"?
那现在问题来了, 该怎么判断代码的耦合程度呢? 或者说, 怎么判断代码是否符合 "高内聚, 松耦合" 呢? 再或者说, 如何判断系统是否需要解耦重构呢?
间接的衡量标准有很多, 前面讲到了一些, 比如, 看修改代码会不会牵一发而动全身. 除此之外, 还有一个直接的衡量标准, 也是我在阅读源码的时候经常会用到的, 那就是把模块与模块之间, 类与类之间的依赖关系画出来, 根据依赖关系图的复杂性来判断是否需要解耦重构.
如果依赖关系复杂, 混乱, 那从代码结构上来讲, 可读性和可维护性肯定不是太好, 那就需要考虑是否可以通过解耦的方法, 让依赖关系变得清晰, 简单. 当然, 这种判断还是有比较强的主观色彩, 但是可以作为一种参考和梳理依赖的手段, 配合间接的衡量标准一块来使用.
# 3.如何给代码"解耦"?
前面能讲了解耦的重要性, 以及如何判断是否需要解耦, 接下来再来看一下, 如何进行解耦.
# (1)封装与抽象
封装和抽象作为两个非常通用的设计思想, 可以应用在很多设计场景中, 比如系统, 模块, lib, 组件, 接口, 类等等的设计. 封装和抽象可以有效地隐藏实现的复杂性, 隔离实现的易变性, 给依赖的模块提供稳定且易用的抽象接口.
比如, Unix 系统提供的 open() 文件操作函数, 用起来非常简单, 但是底层实现却非常复杂, 涉及权限控制, 并发控制, 物理存储等等. 通过将其封装成一个抽象的 open() 函数, 能够有效控制代码复杂性的蔓延, 将复杂性封装在局部代码中. 除此之外, 因为 open() 函数基于抽象而非具体的实现来定义, 所以在改动 open() 函数的底层实现的时候, 并不需要改动依赖它的上层代码, 也符合前面提到的"高内聚, 松耦合"代码的评判标准.
# (2)中间层
引入中间层能简化模块或类之间的依赖关系. 下面这张图是引入中间层前后的依赖关系对比图. 在引入数据存储中间层之前, A, B, C 三个模块都要依赖内存一级缓存, Redis 二级缓存, DB 持久化存储三个模块. 在引入中间层之后, 三个模块只需要依赖数据存储一个模块即可. 从图上可以看出, 中间层的引入明显地简化了依赖关系, 让代码结构更加清晰.

除此之外, 在进行重构的时候, 引入中间层可以起到过渡的作用, 能够让开发和重构同步进行, 不互相干扰. 比如, 某个接口设计得有问题, 需要修改它的定义, 同时, 所有调用这个接口的代码都要做相应的改动. 如果新开发的代码也用到这个接口, 那开发就跟重构冲突了. 为了让重构能小步快跑, 可以分下面四个阶段来完成接口的修改.
- 第一阶段: 引入一个中间层, 包裹老的接口, 提供新的接口定义.
- 第二阶段: 新开发的代码依赖中间层提供的新接口.
- 第三阶段: 将依赖老接口的代码改为调用新接口.
- 第四阶段: 确保所有的代码都调用新接口之后, 删除掉老的接口.
这样, 每个阶段的开发工作量都不会很大, 都可以在很短的时间内完成. 重构跟开发冲突的概率也变小了.
# (3)模块化
模块化是构建复杂系统常用的手段. 不仅在软件行业, 在建筑, 机械制造等行业, 这个手段也非常有用. 对于一个大型复杂系统来说, 没有人能掌控所有的细节. 之所大家以能搭建出如此复杂的系统, 并且能维护得了, 最主要的原因就是将系统划分成各个独立的模块, 让不同的人负责不同的模块, 这样即便在不了解全部细节的情况下, 管理者也能协调各个模块, 让整个系统有效运转.
聚焦到软件开发上面, 很多大型软件(比如 Windows)之所以能做到几百, 上千人有条不紊地协作开发, 也归功于模块化做得好. 不同的模块之间通过 API 来进行通信, 每个模块之间耦合很小, 每个小的团队聚焦于一个独立的高内聚模块来开发, 最终像搭积木一样将各个模块组装起来, 构建成一个超级复杂的系统.
再聚焦到代码层面. 合理地划分模块能有效地解耦代码, 提高代码的可读性和可维护性. 所以, 在开发代码的时候, 一定要有模块化意识, 将每个模块都当作一个独立的 lib 一样来开发, 只提供封装了内部实现细节的接口给其他模块使用, 这样可以减少不同模块之间的耦合度.
实际上, 模块化的思想无处不在, 像 SOA, 微服务, lib 库, 系统内模块划分, 甚至是类, 函数的设计, 都体现了模块化思想. 如果追本溯源, 模块化思想更加本质的东西就是分而治之.
# (4)其他设计思想和原则
"高内聚, 松耦合" 是一个非常重要的设计思想, 能够有效提高代码的可读性和可维护性, 缩小功能改动导致的代码改动范围. 实际上前面已经多次提到过这个设计思想. 很多设计原则都以实现代码的"高内聚, 松耦合"为目的. 来一块总结回顾一下都有哪些原则.
单一职责原则
前面提到, 内聚性和耦合性并非独立的. 高内聚会让代码更加松耦合, 而实现高内聚的重要指导原则就是单一职责原则. 模块或者类的职责设计得单一, 而不是大而全, 那依赖它的类和它依赖的类就会比较少, 代码耦合也就相应的降低了.
基于接口而非实现编程
基于接口而非实现编程能通过接口这样一个中间层, 隔离变化和具体的实现. 这样做的好处是, 在有依赖关系的两个模块或类之间, 一个模块或者类的改动, 不会影响到另一个模块或类. 实际上, 这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合).
依赖注入
跟基于接口而非实现编程思想类似, 依赖注入也是将代码之间的强耦合变为弱耦合. 尽管依赖注入无法将本应该有依赖关系的两个类, 解耦为没有依赖关系, 但可以让耦合关系没那么紧密, 容易做到插拔替换.
多用组合少用继承
继承是一种强依赖关系, 父类与子类高度耦合, 且这种耦合关系非常脆弱, 牵一发而动全身, 父类的每一次改动都会影响所有的子类. 相反, 组合关系是一种弱依赖关系, 这种关系更加灵活, 所以, 对于继承结构比较复杂的代码, 利用组合来替换继承, 也是一种解耦的有效手段.
迪米特法则
迪米特法则讲的是, 不该有直接依赖关系的类之间, 不要有依赖; 有依赖关系的类之间, 尽量只依赖必要的接口. 从定义上, 明显可以看出, 这条原则的目的就是为了实现代码的松耦合.
除了上面讲到的这些设计思想和原则之外, 还有一些设计模式也是为了解耦依赖, 比如观察者模式, 有关这一部分的内容, 留在设计模式模块中讲解.
重点回顾
**1."解耦"为何如此重要? **
过于复杂的代码往往在可读性, 可维护性上都不友好. 解耦保证代码松耦合, 高内聚, 是控制代码复杂度的有效手段. 代码高内聚, 松耦合, 也就是意味着, 代码结构清晰, 分层模块化合理, 依赖关系简单, 模块或类之间的耦合小, 那代码整体的质量就不会差.
**2.代码是否需要"解耦"? **
间接的衡量标准有很多, 比如, 看修改代码是否牵一发而动全身. 直接的衡量标准是把模块与模块, 类与类之间的依赖关系画出来, 根据依赖关系图的复杂性来判断是否需要解耦重构.
**3.如何给代码"解耦"? **
给代码解耦的方法有: 封装与抽象, 中间层, 模块化, 以及一些其他的设计思想与原则, 比如: 单一职责原则, 基于接口而非实现编程, 依赖注入, 多用组合少用继承, 迪米特法则等. 当然, 还有一些设计模式, 比如观察者模式.
# 31-理论五:让你最快速地改善代码质量的20条编程规范(上)
前面讲了很多设计原则, 后面还会讲到很多设计模式, 利用好它们可以有效地改善代码质量. 但是, 这些知识的合理应用非常依赖个人经验, 用不好有时候会适得其反. 而接下来要讲的编码规范正好相反. 编码规范大部分都简单明了, 在代码细节方面, 能立竿见影地改善质量. 除此之外, 前面也讲到, 持续低层次, 小规模重构依赖的基本上都是编码规范, 这也是改善代码可读性的有效手段.
这里我根据我的开发经验, 总结罗列了 20 条个人觉得最好用的编码规范. 掌握这 20 条编码规范, 能你最快速地改善代码质量. 因为内容比较多, 所以分为三节, 分别介绍编码规范的三个部分: 命名与注释(Naming and Comments), 代码风格(Code Style)和编程技巧(Coding Tips) .
# 1.命名
大到项目名, 模块名, 包名, 对外暴露的接口, 小到类名, 函数名, 变量名, 参数名, 只要是做开发, 就逃不过 "起名字" 这一关. 命名的好坏, 对于代码的可读性来说非常重要, 甚至可以说是起决定性作用的. 除此之外, 命名能力也体现了一个程序员的基本编程素养. 这也是我把 "命名" 放到第一个来讲解的原因.
实际上, 命名这件事说难也不难, 关键还是看你重不重视, 愿不愿意花时间. 对于影响范围比较大的命名, 比如包名, 接口, 类名, 一定要反复斟酌, 推敲. 实在想不到好名字的时候, 可以去 GitHub 上用相关的关键词联想搜索一下, 看看类似的代码是怎么命名的.
那具体应该怎么命名呢? 好的命名有啥标准吗? 接下来就从 4 点来讲解我的经验.
# (1)命名多长最合适?
在过往的团队和项目中, 我遇到过两种截然不同的同事. 有一种同事特别喜欢用很长的命名方式, 觉得命名一定要准确达意, 哪怕长一点也没关系, 所以这类同事的项目里, 类名, 函数名都很长. 另外一种同事喜欢用短的命名方式, 能用缩写就尽量用缩写, 所以项目里到处都是包含各种缩写的命名. 你觉得这两种命名方式, 哪种更值得推荐呢?
在我看来, 尽管长的命名可以包含更多的信息, 更能准确直观地表达意图, 但如果函数, 变量的命名很长, 那由它们组成的语句就会很长. 在代码列长度有限制的情况下, 就会经常出现一条语句被分割成两行的情况, 这其实会影响代码可读性.
实际上, 在足够表达其含义的情况下, 命名当然是越短越好. 但是, 大部分情况下, 短的命名都没有长的命名更能达意. 所以很多书籍或者文章都不推荐在命名时使用缩写. 对于一些默认的, 大家都比较熟知的词, 比较推荐用缩写. 这样一方面能让命名短一些, 另一方面又不影响阅读理解, 比如, sec 表示 second, str 表示 string, num 表示 number, doc 表示 document. 除此之外, 对于作用域比较小的变量, 可以使用相对短的命名, 比如一些函数内的临时变量. 相反, 对于类名这种作用域比较大的, 更推荐用长的命名方式.
总之, 命名的一个原则就是以能准确达意为目标. 不过, 对于代码的编写者来说, 自己对代码的逻辑很清楚, 总感觉用什么样的命名都可以达意, 实际上, 对于不熟悉你代码的同事来讲, 可能就不这么认为了. 所以, 命名的时候, 一定要学会换位思考, 假设自己不熟悉这块代码, 从代码阅读者的角度去考量命名是否足够直观.
# (2)利用上下文简化命名
先来看一个简单的例子.
public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}
2
3
4
5
6
在 User 类这样一个上下文中, 没有在成员变量的命名中重复添加 "user" 这样一个前缀单词, 而是直接命名为 name, password, avatarUrl. 在使用这些属性时候, 能借助对象这样一个上下文, 表意也足够明确. 具体代码如下所示:
User user = new User();
user.getName(); // 借助 user 对象这个上下文
2
除了类之外, 函数参数也可以借助函数这个上下文来简化命名. 关于这一点, 我举了下面这个例子.
public void uploadUserAvatarImageToAliyun(String userAvatarImageUri);
// 利用上下文简化为:
public void uploadUserAvatarImageToAliyun(String imageUri);
2
3
# (3)命名要可读,可搜索
这里所说的"可读", 指的是不要用一些特别生僻, 难发音的英文单词来命名.
过去我曾参加过两个项目, 一个叫 plateaux, 另一个叫 eyrie, 从项目立项到结束, 自始至终都没有几个人能叫对这两个项目的名字. 在沟通的时候, 每当有人提到这两个项目的名字的时候, 都会尴尬地卡顿一下. 虽然并不排斥一些独特的命名方式, 但起码得让大部分人看一眼就能知道怎么读. 比如, 我在 Google 参与过的一个项目, 名叫 inkstone, 虽然你不一定知道它表示什么意思, 但基本上都能读得上来, 不影响沟通交流, 这就算是一个比较好的项目命名.
再来讲一下命名可搜索. 在 IDE 中编写代码的时候, 经常会用 "关键词联想" 的方法来自动补全和搜索. 比如, 键入某个对象 ".get", 希望 IDE 返回这个对象的所有 get 开头的方法. 再比如, 通过在 IDE 搜索框中输入 "Array", 搜索 JDK 中数组相关的类. 所以, 在命名的时候, 最好能符合整个项目的命名习惯. 大家都用 "selectXXX" 表示查询, 你就不要用 "queryXXX"; 大家都用 "insertXXX" 表示插入一条数据, 你就要不用 "addXXX", 统一规约是很重要的, 能减少很多不必要的麻烦.
# (4)如何命名接口和抽象类?
对于接口的命名, 一般有两种比较常见的方式. 一种是加前缀 "I", 表示一个 Interface. 比如 IUserService, 对应的实现类命名为 UserService. 另一种是不加前缀, 比如 UserService, 对应的实现类加后缀"Impl", 比如 UserServiceImpl.
对于抽象类的命名, 也有两种方式, 一种是带上前缀 "Abstract", 比如 AbstractConfiguration; 另一种是不带前缀 "Abstract". 实际上, 对于接口和抽象类, 选择哪种命名方式都是可以的, 只要项目里能够统一就行.
# 2.注释
命名很重要, 注释跟命名同等重要. 很多书籍认为, 好的命名完全可以替代注释. 如果需要注释, 那说明命名不够好, 需要在命名上下功夫, 而不是添加注释. 实际上, 我个人觉得, 这样的观点有点太过极端. 命名再好, 毕竟有长度限制, 不可能足够详尽, 而这个时候, 注释就是一个很好的补充.
# (1)注释到底该写什么?
注释的目的就是让代码更容易看懂. 只要符合这个要求的内容, 你就可以将它写到注释里. 总结一下, 注释的内容主要包含这样三个方面: 做什么, 为什么, 怎么做. 来举一个例子解释一下.
/**
* (what) Bean factory to create beans.
*
* (why) The class likes Spring IOC framework, but is more lightweight.
*
* (how) Create objects from different sources sequentially:
* user specified object > SPI > configuration > default object.
*/
public class BeansFactory {
// ...
}
2
3
4
5
6
7
8
9
10
11
有些人认为, 注释是要提供一些代码没有的额外信息, 所以不要写 "做什么, 怎么做", 这两方面在代码中都可以体现出来, 只需要写清楚 "为什么", 表明代码的设计意图即可. 个人不是特别认可这样的观点, 理由主要有下面 3 点.
注释比代码承载的信息更多
命名的主要目的是解释 "做什么". 比如, void increaseWalletAvailableBalance(BigDecimal amount) 表明这个函数用来增加钱包的可用余额, boolean isValidatedPassword 表明这个变量用来标识是否是合法密码. 函数和变量如果命名得好, 确实可以不用再在注释中解释它是做什么的. 但是, 对于类来说, 包含的信息比较多, 一个简单的命名就不够全面详尽了. 这个时候, 在注释中写明"做什么"就合情合理了.
注释起到总结性作用, 文档的作用
代码之下无秘密. 阅读代码可以明确地知道代码是 "怎么做" 的, 也就是知道代码是如何实现的, 那注释中是不是就不用写 "怎么做" 了? 实际上也可以写. 在注释中, 关于具体的代码实现思路, 可以写一些总结性的说明, 特殊情况的说明. 这样能够让阅读代码的人通过注释就能大概了解代码的实现思路, 阅读起来就会更加容易.
实际上, 对于有些比较复杂的类或者接口, 可能还需要在注释中写清楚 "如何用", 举一些简单的 quick start 的例子, 让使用者在不阅读代码的情况下, 快速地知道该如何使用.
一些总结性注释能让代码结构更清晰
对于逻辑比较复杂的代码或者比较长的函数, 如果不好提炼, 不好拆分成小的函数调用, 那可以借助总结性的注释来让代码结构更清晰, 更有条理.
# (2)注释是不是越多越好?
注释太多和太少都有问题. 太多, 有可能意味着代码写得不够可读, 需要写很多注释来补充. 除此之外, 注释太多也会对代码本身的阅读起到干扰. 而且, 后期的维护成本也比较高, 有时候代码改了, 注释忘了同步修改, 就会让代码阅读者更加迷惑. 当然, 如果代码中一行注释都没有, 那只能说明这个程序员很懒, 我们要适当督促一下, 让他注意添加一些必要的注释.
按照经验来说, 类和函数一定要写注释, 而且要写得尽可能全面, 详细, 而函数内部的注释要相对少一些, 一般都是靠好的命名, 提炼函数, 解释性变量, 总结性注释来提高代码的可读性.
重点总结
1.关于命名
命名的关键是能准确达意. 对于不同作用域的命名, 可以适当地选择不同的长度. 作用域小的变量(比如临时变量), 可以适当地选择短一些的命名方式. 除此之外, 命名中也可以使用一些耳熟能详的缩写.
可以借助类的信息来简化属性, 函数的命名, 利用函数的信息来简化函数参数的命名.
命名要可读, 可搜索. 不要使用生僻的, 不好读的英文单词来命名. 除此之外, 命名要符合项目的统一规范, 不要用些反直觉的命名.
接口有两种命名方式: 一种是在接口中带前缀 "I"; 另一种是在接口的实现类中带后缀 "Impl". 对于抽象类的命名, 也有两种方式, 一种是带上前缀 "Abstract", 一种是不带前缀. 这两种命名方式都可以, 关键是要在项目中统一.
2. 关于注释
注释的目的就是让代码更容易看懂. 只要符合这个要求的内容, 你就可以将它写到注释里. 总结一下, 注释的内容主要包含这样三个方面: 做什么, 为什么, 怎么做. 对于一些复杂的类和接口, 可能还需要写明 "如何用".
注释本身有一定的维护成本, 所以并非越多越好. 类和函数一定要写注释, 而且要写得尽可能全面, 详细, 而函数内部的注释要相对少一些, 一般都是靠好的命名, 提炼函数, 解释性变量, 总结性注释来提高代码可读性.
# 32-理论五:让你最快速地改善代码质量的20条编程规范(中)
这一节来讲一下代码风格(Code Style). 说起代码风格, 其实很难说哪种风格更好. 最重要的, 也是最需要做到的, 是在团队, 项目中保持风格统一, 让代码像同一个人写出来的, 整齐划一. 这样能减少阅读干扰, 提高代码的可读性. 这才是在实际工作中想要实现的目标.
关于代码风格, 我总结了 6 点我认为最值得关注的, 一块讨论学习一下.
# 1.类,函数多大才合适?
总体上来讲, 类或函数的代码行数不能太多, 但也不能太少. 类或函数的代码行数太多, 一个类上千行, 一个函数几百行, 逻辑过于繁杂, 阅读代码的时候, 很容易就会看了后面忘了前面. 相反, 类或函数的代码行数太少, 在代码总量相同的情况下, 被分割成的类和函数就会相应增多, 调用关系就会变得更复杂, 阅读某个代码逻辑的时候, 需要频繁地在 n 多类或者 n 多函数之间跳来跳去, 阅读体验也不好.
那一个类或函数有多少行代码才最合适呢?
要给出一个精确的量化值是很难的.
对于函数代码行数的最大限制, 网上有一种说法, 那就是不要超过一个显示屏的垂直高度. 这个说法我觉得挺有道理的. 因为超过一屏之后, 在阅读代码的时候, 为了串联前后的代码逻辑, 就可能需要频繁地上下滚动屏幕, 阅读体验不好不说, 还容易出错.
对于类的代码行数的最大限制, 这个就更难给出一个确切的值了. 前面也给出了一个间接的判断标准, 那就是, 当一个类的代码读起来让你感觉头大了, 实现某个功能时不知道该用哪个函数了, 想用哪个函数翻半天都找不到了, 只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候, 这就说明类的行数过多了.
# 2.一行代码多长最合适?
在 Google Java Style Guide 文档中, 一行代码最长限制为 100 个字符. 不过, 不同的编程语言, 不同的规范, 不同的项目团队, 对此的限制可能都不相同. 不管这个限制是多少, 总体上来讲要遵循的一个原则是: 一行代码最长不能超过 IDE 显示的宽度. 需要滚动鼠标才能查看一行的全部代码, 显然不利于代码的阅读. 当然这个限制也不能太小, 太小会导致很多稍长点的语句被折成两行, 也会影响到代码的整洁, 不利于阅读.
# 3.善用空行分割单元块
对于比较长的函数, 如果逻辑上可以分为几个独立的代码块, 在不方便将这些独立的代码块抽取成小函数的情况下, 为了让逻辑更加清晰, 除了上一节课中提到的用总结性注释的方法之外, 还可以使用空行来分割各个代码块.
除此之外, 在类的成员变量与函数之间, 静态成员变量与普通成员变量之间, 各函数之间, 甚至各成员变量之间, 我们都可以通过添加空行的方式, 让这些不同模块的代码之间, 界限更加明确. 写代码就类似写文章, 善于应用空行, 可以让代码的整体结构看起来更加有清晰, 有条理.
# 4.四格缩进还是两格缩进?
不管是用两格缩进还是四格缩进, 一定不要用 tab 键缩进. 因为在不同的 IDE 下, tab 键的显示宽度不同, 有的显示为四格缩进, 有的显示为两格缩进. 如果在同一个项目中, 不同的同事使用不同的缩进方式(空格缩进或 tab 键缩进), 有可能会导致有的代码显示为两格缩进, 有的代码显示为四格缩进.
# 5.大括号是否要另起一行?
个人还是比较推荐, 将括号放到跟语句同一行的风格. 理由跟上面类似, 节省代码行数. 但是将大括号另起新的一行的方式, 也有它的优势. 这样的话, 左右括号可以垂直对齐, 哪些代码属于哪一个代码块, 更一目了然.
不过, 还是那句话, 大括号跟上一条语句在同一行, 还是另起新的一行, 只要团队统一, 业内统一, 跟开源项目看齐就好了, 没有绝对的优劣之分.
# 6.类中成员的排列顺序
在 Java 类文件中, 先要书写类所属的包名, 然后再罗列 import 引入的依赖类. 在 Google 编码规范中, 依赖类按照字母序从小到大排列.
在类中, 成员变量排在函数的前面. 成员变量之间或函数之间, 都是按照 "先静态(静态函数或静态成员变量), 后普通(非静态函数或非静态成员变量) " 的方式来排列的. 除此之外, 成员变量之间或函数之间, 还会按照作用域范围从大到小的顺序来排列, 先写 public 成员变量或函数, 然后是 protected 的, 最后是 private 的.
不过, 不同的编程语言中, 类内部成员的排列顺序可能会有比较大的差别. 比如 C++ 中, 成员变量会习惯性放到函数后面. 除此之外, 函数之间的排列顺序, 会按照刚刚提到的作用域的大小来排列. 实际上, 还有另外一种排列习惯, 那就是把有调用关系的函数放到一块. 比如, 一个 public 函数调用了另外一个 private 函数, 那就把这两者放到一块.
重点回顾
**1.函数, 类多大才合适? **
函数的代码行数不要超过一屏幕的大小, 比如 50 行. 类的大小限制比较难确定.
**2.一行代码多长最合适? **
最好不要超过 IDE 显示的宽度. 当然, 限制也不能太小, 太小会导致很多稍微长点的语句被折成两行, 也会影响到代码的整洁, 不利于阅读.
3.善用空行分割单元块
对于比较长的函数, 为了让逻辑更加清晰, 可以使用空行来分割各个代码块. 在类内部, 成员变量与函数之间, 静态成员变量与普通成员变量之间, 函数之间, 甚至成员变量之间, 都可以通过添加空行的方式, 让不同模块的代码之间的界限更加明确.
**4.四格缩进还是两格缩进? **
个人比较推荐使用两格缩进, 这样可以节省空间, 特别是在代码嵌套层次比较深的情况下. 除此之外, 值得强调的是, 不管是用两格缩进还是四格缩进, 一定不要用 tab 键缩进.
**5.大括号是否要另起一行? **
个人还是比较推荐将大括号放到跟上一条语句同一行的风格, 这样可以节省代码行数. 但是将大括号另起一行, 也有它的优势, 那就是左右括号可以垂直对齐, 哪些代码属于哪一个代码块, 更加一目了然.
6. 类中成员的排列顺序
在 Google Java 编程规范中, 依赖类按照字母序从小到大排列. 类中先写成员变量后写函数. 成员变量之间或函数之间, 先写静态成员变量或函数, 后写普通变量或函数, 并且按照作用域大小依次排列.
# 33-理论五:让你最快速地改善代码质量的20条编程规范(下)
本节来讲一些比较实用的编程技巧, 帮你切实地提高代码可读性. 这部分技巧比较琐碎, 也很难罗列全面, 我仅仅总结了一些我认为比较关键的, 更多的技巧需要你在实践中自己慢慢总结, 积累.
# 1.把代码分割成更小的单元块
大部分人阅读代码的习惯都是, 先看整体再看细节. 所以要有模块化和抽象思维, 善于将大块的复杂逻辑提炼成类或者函数, 屏蔽掉细节, 让阅读代码的人不至于迷失在细节中, 这样能极大地提高代码的可读性. 不过, 只有代码逻辑比较复杂的时候, 其实才建议提炼类或者函数. 毕竟如果提炼出的函数只包含两三行代码, 在阅读代码的时候, 还得跳过去看一下, 这样反倒增加了阅读成本.
这里举一个例子来进一步解释一下. 代码具体如下所示. 重构前, 在 invest() 函数中, 最开始的那段关于时间处理的代码, 是不是很难看懂? 重构之后, 将这部分逻辑抽象成一个函数, 并且命名为 isLastDayOfMonth, 从名字就能清晰地了解它的功能, 判断今天是不是当月的最后一天. 这里就是通过将复杂的逻辑代码提炼成函数, 大大提高了代码的可读性.
// 重构前的代码
public void invest(long userId, long financialProductId) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return;
}
//...
}
// 重构后的代码: 提炼函数之后逻辑更加清晰
public void invest(long userId, long financialProductId) {
if (isLastDayOfMonth(new Date())) {
return;
}
//...
}
public boolean isLastDayOfMonth(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return true;
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 2.避免函数参数过多
个人觉得, 函数包含 3, 4 个参数的时候还是能接受的, 大于等于 5 个的时候, 就觉得参数有点过多了, 会影响到代码的可读性, 使用起来也不方便. 针对参数过多的情况, 一般有 2 种处理方法.
考虑函数是否职责单一, 是否能通过拆分成多个函数的方式来减少参数.
示例代码如下所示:
public void getUser(String username, String telephone, String email);
// 拆分成多个函数
public void getUserByUsername(String username);
public void getUserByTelephone(String telephone);
public void getUserByEmail(String email);
2
3
4
5
6
将函数的参数封装成对象
示例代码如下所示:
public void postBlog(String title, String summary, String keywords, String content, String category, long authorId);
// 将参数封装成对象
public class Blog {
private String title;
private String summary;
private String keywords;
private Strint content;
private String category;
private long authorId;
}
public void postBlog(Blog blog);
2
3
4
5
6
7
8
9
10
11
12
除此之外, 如果函数是对外暴露的远程接口, 将参数封装成对象, 还可以提高接口的兼容性. 在往接口中添加新的参数的时候, 老的远程接口调用者有可能就不需要修改代码来兼容新的接口了.
# 3.勿用函数参数来控制逻辑
不要在函数中使用布尔类型的标识参数来控制内部逻辑, true 的时候走这块逻辑, false 的时候走另一块逻辑. 这明显违背了单一职责原则和接口隔离原则. 建议将其拆成两个函数, 可读性上也要更好. 举个例子说明一下.
public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
2
3
4
5
不过, 如果函数是 private 私有函数, 影响范围有限, 或者拆分之后的两个函数经常同时被调用, 可以酌情考虑保留标识参数. 示例代码如下所示:
// 拆分成两个函数的调用方式
boolean isVip = false;
//...省略其他逻辑...
if (isVip) {
buyCourseForVip(userId, courseId);
} else {
buyCourse(userId, courseId);
}
// 保留标识参数的调用方式更加简洁
boolean isVip = false;
//...省略其他逻辑...
buyCourse(userId, courseId, isVip);
2
3
4
5
6
7
8
9
10
11
12
13
除了布尔类型作为标识参数来控制逻辑的情况外, 还有一种 "根据参数是否为 null" 来控制逻辑的情况. 针对这种情况, 也应该将其拆分成多个函数. 拆分之后的函数职责更明确, 不容易用错. 具体代码示例如下所示:
public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
if (startDate != null && endDate != null) {
// 查询两个时间区间的transactions
}
if (startDate != null && endDate <mark> null) {
// 查询startDate之后的所有transactions
}
if (startDate </mark> null && endDate != null) {
// 查询endDate之前的所有transactions
}
if (startDate <mark> null && endDate </mark> null) {
// 查询所有的transactions
}
}
// 拆分成多个public函数, 更加清晰, 易用
public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
return selectTransactions(userId, startDate, endDate);
}
public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate) {
return selectTransactions(userId, startDate, null);
}
public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
return selectTransactions(userId, null, endDate);
}
public List<Transaction> selectAllTransactions(Long userId) {
return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 4.函数设计要职责单一
在前面讲到单一职责原则的时候, 针对的是类, 模块这样的应用对象. 实际上, 对于函数的设计来说, 更要满足单一职责原则. 相对于类和模块, 函数的粒度比较小, 代码行数少, 所以在应用单一职责原则的时候, 没有像应用到类或者模块那样模棱两可, 能多单一就多单一.
具体的代码示例如下所示:
public boolean checkUserIfExisting(String telephone, String username, String email) {
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}
if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}
if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}
return false;
}
// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 5.移除过深的嵌套层次
代码嵌套层次过深往往是因为 if-else, switch-case, for 循环过度嵌套导致的. 个人建议, 嵌套最好不超过两层, 超过两层之后就要思考一下是否可以减少嵌套. 过深的嵌套本身理解起来就比较费劲, 除此之外, 嵌套过深很容易因为代码多次缩进, 导致嵌套内部的语句超过一行的长度而折成两行, 影响代码的整洁.
解决嵌套过深的方法也比较成熟, 有下面 4 种常见的思路.
去掉多余的 if 或 else 语句
代码示例如下所示:
// 示例一
public double caculateTotalAmount(List<Order> orders) {
if (orders == null || orders.isEmpty()) {
return 0.0;
} else { // 此处的else可以去掉
double amount = 0.0;
for (Order order : orders) {
if (order != null) {
amount += (order.getCount() * order.getPrice());
}
}
return amount;
}
}
// 示例二
public List<String> matchStrings(List<String> strList,String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str != null) { // 跟下面的if语句可以合并在一起
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
使用 continue, break, return 关键字, 提前退出嵌套.
代码示例如下所示:
// 重构前的代码
public List<String> matchStrings(List<String> strList, String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str != null && str.contains(substr)) {
matchedStrings.add(str);
// 此处还有10行代码...
}
}
}
return matchedStrings;
}
// 重构后的代码: 使用continue提前退出
public List<String> matchStrings(List<String> strList, String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str == null || !str.contains(substr)) {
continue;
}
matchedStrings.add(str);
// 此处还有10行代码...
}
}
return matchedStrings;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
调整执行顺序来减少嵌套.
代码示例如下所示:
// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}
// 重构后的代码: 先执行判空逻辑, 再执行正常逻辑
public List<String> matchStrings(List<String> strList,String substr) {
if (strList <mark> null || substr </mark> null) { // 先判空
return Collections.emptyList();
}
List<String> matchedStrings = new ArrayList<>();
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
return matchedStrings;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
将部分嵌套逻辑封装成函数调用, 以此来减少嵌套.
具体的代码示例如下所示:
// 重构前的代码
public List<String> appendSalts(List<String> passwords) {
if (passwords == null || passwords.isEmpty()) {
return Collections.emptyList();
}
List<String> passwordsWithSalt = new ArrayList<>();
for (String password : passwords) {
if (password <mark> null) {
continue;
}
if (password.length() < 8) {
// ...
} else {
// ...
}
}
return passwordsWithSalt;
}
// 重构后的代码: 将部分逻辑抽成函数
public List<String> appendSalts(List<String> passwords) {
if (passwords </mark> null || passwords.isEmpty()) {
return Collections.emptyList();
}
List<String> passwordsWithSalt = new ArrayList<>();
for (String password : passwords) {
if (password == null) {
continue;
}
passwordsWithSalt.add(appendSalt(password));
}
return passwordsWithSalt;
}
private String appendSalt(String password) {
String passwordWithSalt = password;
if (password.length() < 8) {
// ...
} else {
// ...
}
return passwordWithSalt;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
除此之外, 常用的还有通过使用多态来替代 if-else, switch-case 条件判断的方法. 这个思路涉及代码结构的改动, 会在后面的章节中讲到, 这里就暂时不展开说明了.
# 6.学会使用解释性变量
常用的用解释性变量来提高代码的可读性的情况有下面 2 种.
常量取代魔法数字.
示例代码如下所示:
public double CalculateCircularArea(double radius) {
return (3.1415) * radius * radius;
}
// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
return PI * radius * radius;
}
2
3
4
5
6
7
8
9
使用解释性变量来解释复杂表达式.
示例代码如下所示:
if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
// ...
} else {
// ...
}
// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
重点回顾
这里一起回顾一下前面的几节.
1.关于命名
命名的关键是能准确达意. 对于不同作用域的命名, 可以适当地选择不同的长度.
可以借助类的信息来简化属性, 函数的命名, 利用函数的信息来简化函数参数的命名.
命名要可读, 可搜索. 不要使用生僻的, 不好读的英文单词来命名. 命名要符合项目的统一规范, 也不要用些反直觉的命名.
接口有两种命名方式: 一种是在接口中带前缀 "I"; 另一种是在接口的实现类中带后缀 "Impl". 对于抽象类的命名, 也有两种方式, 一种是带上前缀 "Abstract", 一种是不带前缀. 这两种命名方式都可以, 关键是要在项目中统一.
2.关于注释
注释的内容主要包含这样三个方面: 做什么, 为什么, 怎么做. 对于一些复杂的类和接口, 可能还需要写明 "如何用".
类和函数一定要写注释, 而且要写得尽可能全面详细. 函数内部的注释要相对少一些, 一般都是靠好的命名, 提炼函数, 解释性变量, 总结性注释来提高代码可读性.
3.关于代码风格
函数, 类多大才合适? 函数的代码行数不要超过一屏幕的大小, 比如 50 行. 类的大小限制比较难确定.
一行代码多长最合适? 最好不要超过 IDE 的显示宽度. 当然, 也不能太小, 否则会导致很多稍微长点的语句被折成两行, 也会影响到代码的整洁, 不利于阅读.
善用空行分割单元块. 对于比较长的函数, 为了让逻辑更加清晰, 可以使用空行来分割各个代码块.
四格缩进还是两格缩进? 个人比较推荐使用两格缩进, 这样可以节省空间, 尤其是在代码嵌套层次比较深的情况下. 不管是用两格缩进还是四格缩进, 一定不要用 tab 键缩进.
大括号是否要另起一行? 将大括号放到跟上一条语句同一行, 可以节省代码行数. 但是将大括号另起新的一行的方式, 左右括号可以垂直对齐, 哪些代码属于哪一个代码块, 更加一目了然.
类中成员怎么排列? 在 Google Java 编程规范中, 依赖类按照字母序从小到大排列. 类中先写成员变量后写函数. 成员变量之间或函数之间, 先写静态成员变量或函数, 后写普通变量或函数, 并且按照作用域大小依次排列.
4.关于编码技巧
将复杂的逻辑提炼拆分成函数和类.
通过拆分成多个函数或将参数封装为对象的方式, 来处理参数过多的情况.
函数中不要使用参数来做代码执行逻辑的控制.
函数设计要职责单一.
移除过深的嵌套层次, 方法包括: 去掉多余的 if 或 else 语句, 使用 continue, break, return 关键字提前退出嵌套, 调整执行顺序来减少嵌套, 将部分嵌套逻辑抽象成函数.
用字面常量取代魔法数.
用解释性变量来解释复杂表达式, 以此提高代码可读性.
5.统一编码规范
除了这三节讲到的比较细节的知识点之外, 最后, 还有一条非常重要的, 那就是, 项目, 团队, 甚至公司, 一定要制定统一的编码规范, 并且通过 Code Review 督促执行, 这对提高代码质量有立竿见影的效果.
# 34-实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题
前面几节讲了一些跟重构相关的理论知识, 比如: 持续重构, 单元测试, 代码的可测试性, 解耦, 编码规范. 用一句话总结一下, 重构就是发现代码质量问题, 并且对其进行优化的过程.
前面的内容相对还是偏理论. 今天就借助一个大家都很熟悉的 ID 生成器代码, 展示一下重构的大致过程. 整个内容分为两节课. 这一节讲述如何发现代码质量问题, 下一节课讲述如何针对发现的质量问题, 对其进行优化, 将它从 "能用" 变得 "好用".
# 1.ID生成器需求背景介绍
"ID" 中文翻译为 "标识(Identifier)". 这个概念在生活, 工作中随处可见, 比如身份证, 商品条形码, 二维码, 车牌号, 驾照号. 软件开发中, ID 常用来表示一些业务信息的唯一标识, 比如订单的单号或者数据库中的唯一主键, 比如地址表中的 ID 字段(实际上是没有业务含义的, 对用户来说是透明的, 不需要关注).
假设你正在参与一个后端业务系统的开发, 为了方便在请求出错时排查问题, 我们在编写代码的时候会在关键路径上打印日志. 某个请求出错之后, 我们希望能搜索出这个请求对应的所有日志, 以此来查找问题的原因. 而实际情况是, 在日志文件中, 不同请求的日志会交织在一起. 如果没有东西来标识哪些日志属于同一个请求, 就无法关联同一个请求的所有日志. 这听起来有点像微服务中的调用链追踪. 不过, 微服务中的调用链追踪是服务间的追踪, 现在要实现的是服务内的追踪.
借鉴微服务调用链追踪的实现思路, 可以给每个请求分配一个唯一 ID, 并且保存在请求的上下文(Context)中, 比如, 处理请求的工作线程的局部变量中. 在 Java 语言中, 可以将 ID 存储在 Servlet 线程的 ThreadLocal 中, 或者利用 Slf4j 日志框架的 MDC(Mapped Diagnostic Contexts)来实现(实际上底层原理也是基于线程的 ThreadLocal). 每次打印日志的时候, 从请求上下文中取出请求 ID, 跟日志一块输出. 这样, 同一个请求的所有日志都包含同样的请求 ID 信息, 就可以通过请求 ID 来搜索同一个请求的所有日志了.
需求背景已经讲清楚了, 至于具体如何实现整个需求, 就不展开来讲解了. 如果你感兴趣的话, 可以自己试着去设计实现一下. 接下来只关注其中生成请求 ID 这部分功能的开发.
# 2.一份"能用"的代码实现
假设 leader 让小王负责这个 ID 生成器的开发. 对于稍微有点开发经验的程序员来说, 实现这样一个简单的 ID 生成器, 并不是件难事. 所以, 小王很快就完成了任务, 将代码写了出来, 具体如下所示:
public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);
public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return id;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
上面的代码生成的 ID 示例如下所示. 整个 ID 由三部分组成. 第一部分是本机名的最后一个字段. 第二部分是当前时间戳, 精确到毫秒. 第三部分是 8 位的随机字符串, 包含大小写字母和数字. 尽管这样生成的 ID 并不是绝对唯一的, 有重复的可能, 但事实上重复的概率非常低.
103-1577456311467-3nR3Do45
103-1577456311468-0wnuV5yw
103-1577456311468-sdrnkFxN
103-1577456311468-8lwk0BP0
2
3
4
不过, 在我看来, 像小王的这份代码只能算得上 "能用", 勉强及格. 为啥这么说呢? 这段代码只有短短不到 40 行, 里面却有很多值得优化的地方.
# 3.如何发现代码质量问题?
从大处着眼的话, 可以参考之前讲过的代码质量评判标准, 看这段代码是否可读, 可扩展, 可维护, 灵活, 简洁, 可复用, 可测试等等. 落实到具体细节, 可以从以下几个方面来审视代码.
- 目录设置是否合理, 模块划分是否清晰, 代码结构是否满足 "高内聚, 松耦合"?
- 是否遵循经典的设计原则和设计思想(SOLID, DRY, KISS, YAGNI, LOD 等)?
- 设计模式是否应用得当? 是否有过度设计?
- 代码是否容易扩展? 如果要添加新功能, 是否容易实现?
- 代码是否可以复用? 是否可以复用已有的项目代码或类库? 是否有重复造轮子?
- 代码是否容易测试? 单元测试是否全面覆盖了各种正常和异常的情况?
- 代码是否易读? 是否符合编码规范(比如命名和注释是否恰当, 代码风格是否一致等)?
以上是一些通用的关注点, 可以作为常规检查项, 套用在任何代码的重构上. 除此之外, 还要关注代码实现是否满足业务本身特有的功能和非功能需求. 我罗列了一些比较有共性的问题, 如下所示. 这份列表可能还不够全面, 剩下的需要你针对具体的业务, 具体的代码去具体分析.
- 代码是否实现了预期的业务需求?
- 逻辑是否正确? 是否处理了各种异常情况?
- 日志打印是否得当? 是否方便 debug 排查问题?
- 接口是否易用? 是否支持幂等, 事务等?
- 代码是否存在并发问题? 是否线程安全?
- 性能是否有优化空间, 比如, SQL, 算法是否可以优化?
- 是否有安全漏洞? 比如输入输出校验是否全面?
**现在, 对照上面的检查项来看一下, 小王编写的代码有哪些问题. **
首先, IdGenerator 的代码比较简单, 只有一个类, 所以不涉及目录设置, 模块划分, 代码结构问题, 也不违反基本的 SOLID, DRY, KISS, YAGNI, LOD 等设计原则. 它没有应用设计模式, 所以也不存在不合理使用和过度设计的问题.
其次, IdGenerator 设计成了实现类而非接口, 调用者直接依赖实现而非接口, 违反基于接口而非实现编程的设计思想. 实际上, 将 IdGenerator 设计成实现类, 而不定义接口, 问题也不大. 如果哪天 ID 生成算法改变了, 只需要直接修改实现类的代码就可以. 但如果项目中需要同时存在两种 ID 生成算法, 也就是要同时存在两个 IdGenerator 实现类. 比如, 需要将这个框架给更多的系统来使用. 系统在使用的时候, 可以灵活地选择它需要的生成算法. 这个时候, 就需要将 IdGenerator 定义为接口, 并且为不同的生成算法定义不同的实现类.
再次, 把 IdGenerator 的 generate() 函数定义为静态函数, 会影响使用该函数的代码的可测试性. 同时, generate() 函数的代码实现依赖运行环境(本机名), 时间函数, 随机函数, 所以 generate() 函数本身的可测试性也不好, 需要做比较大的重构. 除此之外, 小王也没有编写单元测试代码, 需要在重构时对其进行补充.
最后, 虽然 IdGenerator 只包含一个函数, 并且代码行数也不多, 但代码的可读性并不好. 特别是随机字符串生成的那部分代码, 一方面, 代码完全没有注释, 生成算法比较难读懂, 另一方面, 代码里有很多魔法数, 严重影响代码的可读性. 在重构的时候, 需要重点提高这部分代码的可读性.
**刚刚参照跟业务本身无关的, 通用的代码质量关注点, 对小王的代码进行了评价. 现在再对照业务本身的功能和非功能需求, 重新审视一下小王的代码. **
前面提到, 虽然小王的代码生成的 ID 并非绝对的唯一, 但是对于追踪打印日志来说, 是可以接受小概率 ID 冲突的, 满足预期的业务需求. 不过, 获取 hostName 这部分代码逻辑貌似有点问题, 并未处理 "hostName 为空" 的情况. 除此之外, 尽管代码中针对获取不到本机名的情况做了异常处理, 但是小王对异常的处理是在 IdGenerator 内部将其吐掉, 然后打印一条报警日志, 并没有继续往上抛出. 这样的异常处理是否得当呢? 可以先思考一下, 我们把这部分内容放到第 36, 37 讲中具体讲解.
小王代码的日志打印得当, 日志描述能够准确反应问题, 方便 debug, 并且没有过多的冗余日志. IdGenerator 只暴露一个 generate() 接口供使用者使用, 接口的定义简单明了, 不存在不易用问题. generate() 函数代码中没有涉及共享变量, 所以代码线程安全, 多线程环境下调用 generate() 函数不存在并发问题.
性能方面, ID 的生成不依赖外部存储, 在内存中生成, 并且日志的打印频率也不会很高, 所以小王的代码在性能方面足以应对目前的应用场景. 不过, 每次生成 ID 都需要获取本机名, 获取主机名会比较耗时, 所以这部分可以考虑优化一下. 还有, randomAscii 的范围是 0~122, 但可用数字仅包含三段子区间(0-9, a-z, A-Z), 极端情况下会随机生成很多三段区间之外的无效数字, 需要循环很多次才能生成随机字符串, 所以随机字符串的生成算法也可以优化一下.
**刚刚还讲到, 有一些代码质量问题不具有共性, 没法一一罗列, 需要针对具体的业务, 具体的代码去具体分析. 那像小王的这份代码, 你还能发现有哪些具体问题吗? **
在 generate() 函数的 while 循环里面, 三个 if 语句内部的代码非常相似, 而且实现稍微有点过于复杂了, 实际上可以进一步简化, 将这三个 if 合并在一起. 具体如何来做, 留在下一节中讲解.
看到这里, 你有没有觉得, 你的内功加深了很多呢? 之前看到一段代码, 想要重构, 但不知道该如何入手, 也不知道该如何评价这段代码写得好坏, 更不知道该如何系统, 全面地进行分析. 而现在, 你可以很轻松地罗列出这段代码的质量缺陷, 并且做到有章可循, 全面系统, 无遗漏. 之所以现在能做到这样, 那是得益于前面很多理论知识的学习和铺垫. 所谓 "会者不难, 难者不会", 其实就是这个道理!
如果没有前面 n 多知识点的铺垫, 比如面向对象和面向过程的区别, 面向对象的四大特性, 面向过程编程的弊端以及如何控制弊端带来的副作用, 需求分析方法, 类的设计思路, 类之间的关系, 接口和抽象类的区别, 各种设计原则和思想等等, 我相信很多人都不能完美地解决今天的问题.
那你可能要说了, 今天这段代码并没有涉及之前所有的知识点啊? 你说得没错. 但是, **如果没有知识点的全面积累, 就无法构建出大的知识框架, 更不知道知识的边界在哪里, 也就无法形成系统的方法论. 即便你能歪打误撞回答全面, 也不会像现在这样对自己的答案如此自信和笃定. **
重点回顾
今天其实就重点讲了一个问题, 那就是, 如何发现代码质量问题? 这其实是我整理的一个发现代码质量问题的 checklist. 之后, 你在 review 自己的代码时, 可以参考这两个 checklist 来进行全面的 review.
首先, 从大处着眼的话, 可以参考之前讲过的代码质量评判标准, 看代码是否可读, 可扩展, 可维护, 灵活, 简洁, 可复用, 可测试等. 落实到具体细节, 可以从以下 7 个方面来审视代码.

这些都是一些通用的关注点, 可以作为一些常规检查项, 套用在任何代码的重构上. 除此之外, 还要关注代码实现是否满足业务本身特有的功能和非功能需求. 一些比较共性的关注点如下所示:

# 35-实战一(下):手把手带你将ID生成器代码从"能用"重构为"好用"
上一节结合 ID 生成器代码讲解了如何发现代码质量问题. 虽然 ID 生成器的需求非常简单, 代码行数也不多, 但看似非常简单的代码, 实际上还是有很多优化的空间. 综合评价一下的话, 小王的代码也只能算是 "能用", 勉强及格. 大部分人写出来的代码都能达到这个程度. 如果想要在团队中脱颖而出, 就不能只满足于这个 60 分及格, 大家都能做的事情, 要做得更好才行. 这一节课再讲一下, 如何让它从"能用"变得"好用".
# 1.制定重构计划
前面讲到系统设计和实现的时候, 多次讲到要循序渐进, 小步快跑. 重构代码的过程也应该遵循这样的思路. 每次改动一点点, 改好之后, 再进行下一轮的优化, 保证每次对代码的改动不会过大, 能在很短的时间内完成. 所以将上一节课中发现的代码质量问题, 分成四次重构来完成, 具体如下所示.
- 第一轮重构: 提高代码的可读性
- 第二轮重构: 提高代码的可测试性
- 第三轮重构: 编写完善的单元测试
- 第四轮重构: 所有重构完成之后添加注释
# 2.第一轮重构:提高代码的可读性
首先要解决最明显, 最急需改进的代码可读性问题. 具体有下面几点:
- hostName 变量不应该被重复使用, 尤其当这两次使用时的含义还不同的时候;
- 将获取 hostName 的代码抽离出来, 定义为 getLastfieldOfHostName() 函数;
- 删除代码中的魔法数, 比如, 57, 90, 97, 122;
- 将随机数生成的代码抽离出来, 定义为 generateRandomAlphameric() 函数;
- generate() 函数中的三个 if 逻辑重复了, 且实现过于复杂, 要对其进行简化;
- 对 IdGenerator 类重命名, 并且抽象出对应的接口.
这里重点讨论下最后一个修改. 实际上, 对于 ID 生成器的代码, 有下面三种类的命名方式. 你觉得哪种更合适呢?

来逐一分析一下三种命名方式.
第一种命名方式, 将接口命名为 IdGenerator, 实现类命名为 LogTraceIdGenerator, 这可能是很多人最先想到的命名方式了. 在命名的时候, 要考虑到, 以后两个类会如何使用, 会如何扩展. 从使用和扩展的角度来分析, 这样的命名就不合理了.
首先, 如果扩展新的日志 ID 生成算法, 也就是要创建另一个新的实现类, 因为原来的实现类已经叫 LogTraceIdGenerator 了, 命名过于通用, 那新的实现类就不好取名了, 无法取一个跟 LogTraceIdGenerator 平行的名字了.
其次, 你可能会说, 假设没有日志 ID 的扩展需求, 但要扩展其他业务的 ID 生成算法, 比如针对用户的(UserldGenerator), 订单的(OrderIdGenerator), 第一种命名方式是不是就是合理的呢? 答案也是否定的. 基于接口而非实现编程, 主要的目的是为了方便后续灵活地替换实现类. 而 LogTraceIdGenerator, UserIdGenerator, OrderIdGenerator 三个雷从命名上来看, 涉及的是完全不同的业务, 不存在互相替换的场景. 也就是说, 不可能在有关日志的代码中, 进行下面这种替换. 所以, 让这三个类实现同一个接口, 实际上是没有意义的.
IdGenearator idGenerator = new LogTraceIdGenerator();
替换为:
IdGenearator idGenerator = new UserIdGenerator();
2
3
第二种命名方式是不是就合理了呢? 答案也是否定的. 其中, LogTraceIdGenerator 接口的命名是合理的, 但是 HostNameMillisIdGenerator 实现类暴露了太多实现细节, 只要代码稍微有所改动, 就可能需要改动命名, 才能匹配实现.
第三种命名方式是比较推荐的. 在目前的 ID 生成器代码实现中, 生成的 ID 是一个随机 ID, 不是递增有序的, 所以, 命名成 RandomIdGenerator 是比较合理的, 即便内部生成算法有所改动, 只要生成的还是随机的 ID, 就不需要改动命名. 如果需要扩展新的 ID 生成算法, 比如要实现一个递增有序的 ID 生成算法, 那可以命名为 SequenceIdGenerator.
实际上, 更好的一种命名方式是, 抽象出两个接口, 一个是 IdGenerator, 一个是 LogTraceIdGenerator, LogTraceIdGenerator 继承 IdGenerator. 实现类实现接口 IdGenerator, 命名为 RandomIdGenerator, SequenceIdGenerator 等. 这样实现类可以复用到多个业务模块中, 比如前面提到的用户, 订单.
根据上面的优化策略, 对代码进行第一轮的重构, 重构之后的代码如下所示:
public interface IdGenerator {
String generate();
}
public interface LogTraceIdGenerator extends IdGenerator {
}
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
private String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
// 代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 3.第二轮重构:提高代码的可测试性
关于代码可测试性的问题, 主要包含下面两个方面:
- generate() 函数定义为静态函数, 会影响使用该函数的代码的可测试性;
- generate() 函数的代码实现依赖运行环境(本机名), 时间函数, 随机函数, 所以 generate() 函数本身的可测试性也不好.
对于第一点, 已经在第一轮重构中解决了. 将 RandomIdGenerator 类中的 generate() 静态函数重新定义成了普通函数. 调用者可以通过依赖注入的方式, 在外部创建好 RandomIdGenerator 对象后注入到自己的代码中, 从而解决静态函数调用影响代码可测试性的问题.
对于第二点, 需要在第一轮重构的基础之上再进行重构. 重构之后的代码如下所示, 主要包括以下几个代码改动.
- 从 getLastfieldOfHostName() 函数中, 将逻辑比较复杂的那部分代码剥离出来, 定义为 getLastSubstrSplittedByDot() 函数. 因为 getLastfieldOfHostName() 函数依赖本地主机名, 所以, 剥离出主要代码之后这个函数变得非常简单, 可以不用测试. 重点测试 getLastSubstrSplittedByDot() 函数即可.
- 将 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 这两个函数的访问权限设置为 protected. 这样做的目的是, 可以直接在单元测试中通过对象来调用两个函数进行测试.
- 给 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 两个函数添加 Google Guava 的 annotation @VisibleForTesting. 这个 annotation 没有任何实际的作用, 只起到标识的作用, 告诉其他人说, 这两个函数本该是 private 访问权限的, 之所以提升访问权限到 protected, 只是为了测试, 只能用于单元测试中.
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
打印日志的 Logger 对象被定义为 static final 的, 并且在类内部创建, 这是否影响到代码的可测试性? 是否应该将 Logger 对象通过依赖注入的方式注入到类中呢?
依赖注入之所以能提高代码可测试性, 主要是因为通过这样的方式能轻松地用 mock 对象替换依赖的真实对象. 那为什么要 mock 这个对象呢? 这是因为, 这个对象参与逻辑执行(比如, 我们要依赖它输出的数据做后续的计算)但又不可控. 对于 Logger 对象来说, 只往里写入数据, 并不读取数据, 不参与业务逻辑的执行, 不会影响代码逻辑的正确性, 所以没有必要 mock Logger 对象.
除此之外, 一些只是为了存储数据的值对象, 比如 String, Map, UseVo, 也没必要通过依赖注入的方式来创建, 直接在类中通过 new 创建就可以了.
# 4.第三轮重构:编写完善的单元测试
经过上面的重构之后, 代码存在的比较明显的问题, 基本上都已经解决了. 现在为代码补全单元测试. RandomIdGenerator 类中有 4 个函数.
public String generate();
private String getLastfieldOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length);
2
3
4
5
6
先来看后两个函数. 这两个函数包含的逻辑比较复杂, 是测试的重点. 而且在上一步重构中, 为了提高代码的可测试性, 已经把这两个部分代码跟不可控的组件(本机名, 随机函数, 时间函数)进行了隔离. 所以, 只需要设计完备的单元测试用例即可. 具体的代码实现如下所示(注意使用了 Junit 测试框架):
public class RandomIdGeneratorTest {
@Test
public void testGetLastSubstrSplittedByDot() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1.field2.field3");
Assert.assertEquals("field3", actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1");
Assert.assertEquals("field1", actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1#field2$field3");
Assert.assertEquals("field1#field2#field3", actualSubstr);
}
// 此单元测试会失败, 因为我们在代码中没有处理hostName为null或空字符串的情况
// 这部分优化留在第36, 37节课中讲解
@Test
public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
Assert.assertNull(actualSubstr);
actualSubstr = idGenerator.getLastSubstrSplittedByDot("");
Assert.assertEquals("", actualSubstr);
}
@Test
public void testGenerateRandomAlphameric() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(6);
Assert.assertNotNull(actualRandomString);
Assert.assertEquals(6, actualRandomString.length());
for (char c : actualRandomString.toCharArray()) {
Assert.assertTrue(('0' < c && c > '9') || ('a' < c && c > 'z') || ('A' < c && c < 'Z'));
}
}
// 此单元测试会失败, 因为我们在代码中没有处理length<=0的情况
// 这部分优化留在第36, 37节课中讲解
@Test
public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
RandomIdGenerator idGenerator = new RandomIdGenerator();
String actualRandomString = idGenerator.generateRandomAlphameric(0);
Assert.assertEquals("", actualRandomString);
actualRandomString = idGenerator.generateRandomAlphameric(-1);
Assert.assertNull(actualRandomString);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
再来看 generate() 函数. 这个函数也是唯一一个暴露给外部使用的 public 函数. 虽然逻辑比较简单, 最好还是测试一下. 但是, 它依赖主机名, 随机函数, 时间函数, 该如何测试呢? 需要 mock 这些函数的实现吗?
实际上, 这要分情况来看. 前面讲过, 写单元测试的时候, 测试对象是函数定义的功能, 而非具体的实现逻辑. 这样才能做到函数的实现逻辑改变了之后, 单元测试用例仍然可以工作. 那 generate() 函数实现的功能是什么呢? 这完全是由代码编写者自己来定义的.
比如, 针对同一份 generate() 函数的代码实现, 可以有 3 种不同的功能定义, 对应 3 种不同的单元测试.
如果把 generate() 函数的功能定义为: "生成一个随机唯一 ID", 那只要测试多次调用 generate() 函数生成的 ID 是否唯一即可.
如果把 generate() 函数的功能定义为: "生成一个只包含数字, 大小写字母和中划线的唯一 ID", 那不仅要测试 ID 的唯一性, 还要测试生成的 ID 是否只包含数字, 大小写字母和中划线.
如果把 generate() 函数的功能定义为: "生成唯一 ID, 格式为: "主机名 substr"-"时间戳"-"8 位随机数". 在主机名获取失败时, 返回: null-"时间戳"-"8 位随机数", 那不仅要测试 ID 的唯一性, 还要测试生成的 ID 是否完全符合格式要求.
**总结一下, 单元测试用例如何写, 关键看你如何定义函数. ** 针对 generate() 函数的前两种定义, 不需要 mock 获取主机名函数, 随机函数, 时间函数等, 但对于第 3 种定义, 需要 mock 获取主机名函数, 让其返回 null, 测试代码运行是否符合预期.
最后, 来看下 getLastfieldOfHostName() 函数. 实际上, 这个函数不容易测试, 因为它调用了一个静态函数(InetAddress.getLocalHost().getHostName()😉, 并且这个静态函数依赖运行环境. 但是, 这个函数的实现非常简单, 肉眼基本上可以排除明显的 bug, 所以可以不为其编写单元测试代码. 毕竟写单元测试的目的是为了减少代码 bug, 而不是为了写单元测试而写单元测试.
当然, 如果你真的想要对它进行测试, 也是有办法的. 一种办法是使用更加高级的测试框架. 比如 PowerMock, 它可以 mock 静态函数. 另一种方式是将获取本机名的逻辑再封装为一个新的函数. 不过, 后一种方法会造成代码过度零碎, 也会稍微影响到代码的可读性, 这个需要去权衡利弊来做选择.
# 5.第四轮重构:添加注释
前面提到, 注释不能太多, 也不能太少, 主要添加在类和函数上. 有人说, 好的命名可以替代注释, 清晰的表达含义. 这点对于变量的命名来说是适用的, 但对于类或函数来说就不一定对了. 类或函数包含的逻辑往往比较复杂, 单纯靠命名很难清晰地表明实现了什么功能, 这个时候就需要通过注释来补充. 比如, 前面提到的对于 generate() 函数的 3 种功能定义, 就无法用命名来体现, 需要补充到注释里面.
总结一下, 主要就是写清楚: 做什么, 为什么, 怎么做, 怎么用, 对一些边界条件, 特殊情况进行说明, 以及对函数输入, 输出, 异常进行说明.
/**
* Id Generator that is used to generate random IDs.
*
* <p>
* The IDs generated by this class are not absolutely unique,
* but the probability of duplication is very low.
*/
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
/**
* Generate the random ID. The IDs may be duplicated only in extreme situation.
*
* @return an random ID
*/
@Override
public String generate() {
//...
}
/**
* Get the local hostname and
* extract the last field of the name string splitted by delimiter '.'.
*
* @return the last field of hostname. Returns null if hostname is not obtained.
*/
private String getLastfieldOfHostName() {
//...
}
/**
* Get the last field of {@hostName} splitted by delemiter '.'.
*
* @param hostName should not be null
* @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
*/
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
重点回顾
实际上, 通过这节课, 更想传达的是下面这样几个开发思想.
即便是非常简单的需求, 不同水平的人写出来的代码, 差别可能会很大. 要对代码质量有所追求, 不能只是凑活能用就好. 花点心思写一段高质量的代码, 比写 100 段凑活能用的代码, 对你的代码能力提高更有帮助.
知其然知其所以然, 了解优秀代码设计的演变过程, 比学习优秀设计本身更有价值. 知道为什么这么做, 比单纯地知道怎么做更重要, 这样可以避免你过度使用设计模式, 思想和原则.
设计思想, 原则, 模式本身并没有太多 "高大上" 的东西, 都是一些简单的道理, 而且知识点也并不多, 关键还是锻炼具体代码具体分析的能力, 把知识点恰当地用在项目中.
我经常讲, 高手之间的竞争都是在细节. 大的架构设计, 分层, 分模块思路实际上都差不多. 没有项目是靠一些不为人知的设计来取胜的, 即便有, 很快也能被学习过去. 所以关键还是看代码细节处理得够不够好. 这些细节的差别累积起来, 会让代码质量有质的差别. 所以, 要想提高代码质量, 还是要在细节处下功夫.
# 36-实战二(上):程序出错该返回啥?NULL,异常,错误码,空对象?
可以把函数的运行结果分为两类. 一类是预期的结果, 也就是函数在正常情况下输出的结果. 一类是非预期的结果, 也就是函数在异常(或叫出错)情况下输出的结果. 比如上一节获取本机名的函数, 在正常情况下, 函数返回字符串格式的本机名; 在异常情况下, 获取本机名失败, 函数返回 UnknownHostException 异常对象.
在正常情况下, 函数返回数据的类型非常明确, 但是在异常情况下, 函数返回的数据类型却非常灵活, 有多种选择. 除了刚刚提到的类似 UnknownHostException 这样的异常对象之外, 函数在异常情况下还可以返回错误码, NULL 值, 特殊值(比如 -1), 空对象(比如空字符串, 空集合) 等.
每一种异常返回数据类型, 都有各自的特点和适用场景. 但有的时候, 在异常情况下, 函数到底该返回什么样的数据类型, 并不那么容易判断. 比如上节中, 在本机名获取失败的时候, ID 生成器的 generate() 函数应该返回什么呢? 是异常? 空字符? 还是 NULL 值? 又或者是其他特殊值(比如 null-15293834874-fd3A9KBn, null 表示本机名未获取到)呢?
函数是代码的一个非常重要的编写单元, 而函数的异常处理, 又是在编写函数的时候, 时刻都要考虑的. 今天就聊一聊, 如何设计函数在异常情况下的返回数据类型.
# 1.从上节的ID生成器代码讲起
上两节中把一份非常简单的 ID 生成器的代码, 从 "能用" 重构成了 "好用". 最终给出的代码看似已经很完美了, 但是如果再用心推敲一下, 代码中关于出错处理的方式, 还有进一步优化的空间, 值得拿出来再讨论一下.
为方便查看, 将上节课的代码拷贝到了这里.
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastFiledOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastFiledOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
这段代码中有四个函数. 针对这四个函数的出错处理方式, 我总结出下面这样几个问题.
- 对于 generate() 函数, 如果本机名获取失败, 函数返回什么? 这样的返回值是否合理?
- 对于 getLastFiledOfHostName() 函数, 是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)? 还是应该将异常继续往上抛出? 如果往上抛出的话, 是直接把 UnknownHostException 异常原封不动地抛出, 还是封装成新的异常抛出?
- 对于 getLastSubstrSplittedByDot(String hostName) 函数, 如果 hostName 为 NULL 或者是空字符串, 这个函数应该返回什么?
- 对于 generateRandomAlphameric(int length) 函数, 如果 length 小于 0 或者等于 0, 这个函数应该返回什么?
对于上面这几个问题, 下一节会进行分析.
# 2.函数出错应该返回啥?
关于函数出错返回数据类型, 我总结了 4 种情况, 它们分别是: 错误码, NULL 值, 空对象, 异常对象. 接下来就一一来看它们的用法以及适用场景.
# (1)返回错误码
C 语言中没有异常这样的语法机制, 因此返回错误码便是最常用的出错处理方式. 而在 Java, Python 等比较新的编程语言中, 大部分情况下, 都用异常来处理函数出错的情况, 极少会用到错误码.
在 C 语言中, 错误码的返回方式有两种: 一种是直接占用函数的返回值, 函数正常执行的返回值放到出参中; 另一种是将错误码定义为全局变量, 在函数执行出错时, 函数调用者通过这个全局变量来获取错误码. 针对这两种方式, 举个例子来进一步解释. 具体代码如下:
// 错误码的返回方式一: pathname/flags/mode为入参; fd为出参, 存储打开的文件句柄.
int open(const char *pathname, int flags, mode_t mode, int* fd) {
if (/*文件不存在*/) {
return EEXIST;
}
if (/*没有访问权限*/) {
return EACCESS;
}
if (/*打开文件成功*/) {
return SUCCESS; // C语言中的宏定义: #define SUCCESS 0
}
// ...
}
// 使用举例
int fd;
int result = open("c:\test.txt", O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result <mark> SUCCESS) {
// 取出fd使用
} else if (result </mark> EEXIST) {
//...
} else if (result == EACESS) {
//...
}
// 错误码的返回方式二: 函数返回打开的文件句柄, 错误码放到errno中.
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode){
if (/*文件不存在*/) {
errno = EEXIST;
return -1;
}
if (/*没有访问权限*/) {
errno = EACCESS;
return -1;
}
// ...
}
// 使用举例
int hFile = open("c:\test.txt", O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 <mark> hFile) {
printf("Failed to open file, error no: %d.\n", errno);
if (errno </mark> EEXIST ) {
// ...<br />} else if(errno == EACCESS) {
// ...<br />}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
实际上, 如果你熟悉的编程语言中有异常这种语法机制, 那就尽量不要使用错误码. 异常相对于错误码, 有诸多方面的优势, 比如可以携带更多的错误信息(exception 中可以有 message, stack trace 等信息)等.
# (2)返回NULL值
在多数编程语言中, 用 NULL 来表示 "不存在" 这种语义. 不过, 网上很多人不建议函数返回 NULL 值, 认为这是一种不好的设计思路, 主要的理由有以下两个.
- 如果某个函数有可能返回 NULL 值, 在使用它的时候, 忘记了做 NULL 值判断, 就有可能会抛出空指针异常(Null Pointer Exception, 缩写为 NPE).
- 如果定义了很多返回值可能为 NULL 的函数, 那代码中就会充斥着大量的 NULL 值判断逻辑, 一方面写起来比较繁琐, 另一方面它们跟正常的业务逻辑耦合在一起, 会影响代码的可读性.
举个例子解释一下, 具体代码如下所示:
public class UserService {
private UserRepo userRepo; // 依赖注入
public User getUser(String telephone) {
// 如果用户不存在, 则返回null
return null;
}
}
class Test {
public static void main(String[] args) {
// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判断, 否则有可能会报NPE
String email = user.getEmail();
if (email != null) { // 做NULL值判断, 否则有可能会报NPE
String escapedEmail = email.replaceAll("@", "#");
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
那是否可以用异常来替代 NULL 值, 在查找用户不存在的时候, 让函数抛出 UserNotFoundException 异常呢?
个人觉得, 尽管返回 NULL 值有诸多弊端, 但对于以 get, find, select, search, query 等单词开头的查找函数来说, 数据不存在, 并非一种异常情况, 这是一种正常行为. 所以, 返回代表不存在语义的 NULL 值比返回异常更加合理.
不过, 话说回来, 刚刚讲的这个理由, 也并不是特别有说服力. 对于查找数据不存在的情况, 函数到底是该用 NULL 值还是异常, 有一个比较重要的参考标准是, 看项目中的其他类似查找函数都是如何定义的, 只要整个项目遵从统一的约定即可. 如果项目从零开始开发, 并没有统一约定和可以参考的代码, 那选择两者中的任何一种都可以. 只需要在函数定义的地方注释清楚, 让调用者清晰地知道数据不存在的时候会返回什么就可以了.
再补充说明一点, 对于查找函数来说, 除了返回数据对象之外, 有的还会返回下标位置, 比如 Java 中的 indexOf() 函数, 用来实现在某个字符串中查找另一个子串第一次出现的位置. 函数的返回值类型为基本类型 int. 这个时候, 就无法用 NULL 值来表示不存在的情况了. 对于这种情况, 有两种处理思路, 一种是返回 NotFoundException, 一种是返回一个特殊值, 比如 -1. 不过, 显然 -1 更加合理, 理由也是同样的, 也就是说 "没有查找到" 是一种正常而非异常的行为.
# (3)返回空对象
刚刚讲到, 返回 NULL 值有各种弊端. 应对这个问题有一个比较经典的策略, 那就是应用空对象设计模式(Null Object Design Pattern). 关于这个设计模式, 后面章节会详细讲, 现在就不展开来讲解了. 今天来讲两种比较简单, 比较特殊的空对象, 那就是空字符串和空集合.
当函数返回的数据是字符串类型或者集合类型的时候, 可以用空字符串或空集合替代 NULL 值, 来表示不存在的情况. 这样在使用函数的时候, 就可以不用做 NULL 值判断. 举个例子:
// 使用空集合替代NULL
public class UserService {
private UserRepo userRepo; // 依赖注入
public List<User> getUsers(String telephonePrefix) {
// 没有查找到数据
return Collectiosn.emptyList();
}
}
// getUsers使用示例
List<User> users = userService.getUsers("189");
for(User user : users){ // 这里不需要做NULL值判断
// ...
}
// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text){
// 如果text中没有大写字母, 返回空字符串, 而非NULL值
return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters=retrieveUppercaseLetters("wangzheng");
int length=uppercaseLetters.length(); // 不需要做NULL值判断
System.out.println("Contains "+length+" upper case letters.");
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 3.抛出异常对象
尽管前面讲了很多函数出错的返回数据类型, 但是, 最常用的函数出错处理方式就是抛出异常. 异常可以携带更多的错误信息, 比如函数调用栈信息. 除此之外, 异常可以将正常逻辑和异常逻辑的处理分离开来, 这样代码的可读性就会更好.
不同的编程语言的异常语法稍有不同. 像 C++ 和大部分的动态语言(Python, Ruby, JavaScript 等)都只定义了一种异常类型: 运行时异常(Runtime Exception). 而像 Java, 除了运行时异常外, 还定义了另外一种异常类型: 编译时异常(Compile Exception).
对于运行时异常, 在编写代码的时候, 可以不用主动去 try-catch, 编译器在编译代码的时候, 并不会检查代码是否有对运行时异常做了处理. 相反, 对于编译时异常, 在编写代码的时候, 需要主动去 try-catch 或者在函数定义中声明, 否则编译就会报错. 所以运行时异常也叫作非受检异常(Unchecked Exception), 编译时异常也叫作受检异常(Checked Exception).
如果你熟悉的编程语言中, 只定义了一种异常类型, 那用起来反倒比较简单. 如果你熟悉的编程语言中(比如 Java), 定义了两种异常类型, 那在异常出现的时候, 应该选择抛出哪种异常类型呢? 是受检异常还是非受检异常?
对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败), 即便捕获了, 也做不了太多事情, 所以倾向于使用非受检异常. 对于可恢复异常, 业务异常, 比如提现金额大于余额的异常, 更倾向于使用受检异常, 明确告知调用者需要捕获处理.
举个例子. 当 Redis 的地址(参数 address)没有设置的时候, 直接使用默认的地址(比如本地地址和默认端口); 当 Redis 的地址格式不正确的时候, 希望程序能 fail-fast, 也就是说, 把这种情况当成不可恢复的异常, 直接抛出运行时异常, 将程序终止掉.
// address格式: "192.131.2.33:7896"
public void parseRedisAddress(String address) {
this.host = RedisConfig.DEFAULT_HOST;
this.port = RedisConfig.DEFAULT_PORT;
if (StringUtils.isBlank(address)) {
return;
}
String[] ipAndPort = address.split(":");
if (ipAndPort.length != 2) {
throw new RuntimeException("...");
}
this.host = ipAndPort[0];
// parseInt()解析失败会抛出NumberFormatException运行时异常
this.port = Integer.parseInt(ipAndPort[1]);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
实际上, Java 支持的受检异常一直被人诟病, 很多人主张所有的异常情况都应该使用非受检异常. 支持这种观点的理由主要有以下三个.
- 受检异常需要显式地在函数定义中声明. 如果函数会抛出很多受检异常, 那函数的定义就会非常冗长, 这就会影响代码的可读性, 使用起来也不方便.
- 编译器强制必须显示地捕获所有的受检异常, 代码实现会比较繁琐. 而非受检异常正好相反, 不需要在定义中显示声明, 并且是否需要捕获处理, 也可以自由决定.
- 受检异常的使用违反开闭原则. 如果给某个函数新增一个受检异常, 这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改, 直到调用链中的某个函数将这个新增的异常 try-catch 处理掉为止. 而新增非受检异常可以不改动调用链上的代码. 可以灵活地选择在某个函数中集中处理, 比如在 Spring 中的 AOP 切面中集中处理异常.
不过, 非受检异常也有弊端, 它的优点其实也正是它的缺点. 从刚刚的表述中, 可以看出, 非受检异常使用起来更加灵活, 怎么处理的主动权这里就交给了程序员. 前面也讲到, 过于灵活会带来不可控, 非受检异常不需要显式地在函数定义中声明, 那在使用函数的时候, 就需要查看代码才能知道具体会抛出哪些异常. 非受检异常不需要强制捕获处理, 那程序员就有可能漏掉一些本应该捕获处理的异常.
对于应该用受检异常还是非受检异常, 网上的争论有很多, 但并没有一个非常强有力的理由能够说明一个就一定比另一个更好. 所以, 只需要根据团队的开发习惯, 在同一个项目中, 制定统一的异常处理规范即可.
**刚刚讲了两种异常类型, 现在再来讲下, 如何处理函数抛出的异常? ** 总结一下, 一般有下面三种处理方法.
直接吞掉.
代码示例如下所示:
public void func1() throws Exception1 {
// ...
}
public void func2() {
//...
try {
func1();
} catch(Exception1 e) {
log.warn("...", e); //吐掉: try-catch打印日志
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
原封不动地 re-throw.
代码示例如下所示:
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception1 {// 原封不动的re-throw Exception1
//...
func1();
//...
}
2
3
4
5
6
7
8
9
包装成新的异常 re-throw.
代码示例如下所示:
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception2 {
//...
try {
func1();
} catch(Exception1 e) {
throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
当面对函数抛出异常的时候, 应该选择上面的哪种处理方式呢? 我总结了下面三个参考原则:
- 如果 func1() 抛出的异常是可以恢复, 且 func2() 的调用方并不关心此异常, 完全可以在 func2() 内将 func1() 抛出的异常吞掉;
- 如果 func1() 抛出的异常对 func2() 的调用方来说, 也是可以理解的, 关心的, 并且在业务概念上有一定的相关性, 可以选择直接将 func1 抛出的异常 re-throw;
- 如果 func1() 抛出的异常太底层, 对 func2() 的调用方来说, 缺乏背景去理解, 且业务概念上无关, 可以将它重新包装成调用方可以理解的新异常, 然后 re-throw.
总之, 是否往上继续抛出, 要看上层代码是否关心这个异常. 关心就将它抛出, 否则就直接吞掉. 是否需要包装成新的异常抛出, 看上层代码是否能理解这个异常, 是否业务相关. 如果能理解, 业务相关就可以直接抛出, 否则就封装成新的异常抛出.
重点回顾
对于函数出错返回数据类型, 我总结了 4 种情况, 它们分别是: 错误码, NULL 值, 空对象, 异常对象.
1.返回错误码
C 语言没有异常这样的语法机制, 返回错误码便是最常用的出错处理方式. 而 Java, Python 等比较新的编程语言中, 大部分情况下, 都用异常来处理函数出错的情况, 极少会用到错误码.
2.返回 NULL 值
在多数编程语言中, 用 NULL 来表示"不存在"这种语义. 对于查找函数来说, 数据不存在并非一种异常情况, 是一种正常行为, 所以返回表示不存在语义的 NULL 值比返回异常更加合理.
3.返回空对象
返回 NULL 值有各种弊端, 对此有一个比较经典的应对策略, 那就是应用空对象设计模式. 当函数返回的数据是字符串类型或者集合类型的时候, 可以用空字符串或空集合替代 NULL 值, 来表示不存在的情况. 这样在使用函数的时候, 就可以不用做 NULL 值判断.
4.抛出异常对象
尽管前面讲了很多函数出错的返回数据类型, 但最常用的函数出错处理方式是抛出异常. 异常有两种类型: 受检异常和非受检异常.
对于应该用受检异常还是非受检异常, 网上的争论有很多, 但也并没有一个非常强有力的理由, 说明一个就一定比另一个更好. 所以只需要根据团队的开发习惯, 在同一个项目中, 制定统一的异常处理规范即可.
对于函数抛出的异常, 有三种处理方法: 直接吞掉, 直接往上抛出, 包裹成新的异常抛出.
# 37-实战二(下):重构ID生成器项目中各函数的异常处理代码
平时进行软件设计开发的时候, 除了要保证正常情况下的逻辑运行正确之外, 还需要编写大量额外的代码, 来处理有可能出现的异常情况, 以保证代码不会出现非预期的运行结果. 程序的 bug 往往都出现在一些边界条件和异常情况下, 所以说, 异常处理得好坏直接影响了代码的健壮性. 全面, 合理地处理各种异常能有效减少代码 bug, 也是保证代码质量的一个重要手段.
上一节讲了几种异常情况的处理方式, 比如返回错误码, NULL 值, 空对象, 异常对象. 针对最常用的异常对象, 还重点讲解了两种异常类型的应用场景, 以及针对函数抛出的异常的三种处理方式: 直接吞掉, 原封不动地抛出和包裹成新的异常抛出.
在上一节的开头, 还针对 ID 生成器的代码, 提出了 4 个有关异常处理的问题. 今天就结合上一节讲到的理论知识, 来逐一解答一下这几个问题.
# 1.重构generate()函数
首先来看, 对于 generate() 函数, 如果本机名获取失败, 函数返回什么? 这样的返回值是否合理?
public String generate() {
String substrOfHostName = getLastFiledOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
2
3
4
5
6
7
8
ID 由三部分构成: 本机名, 时间戳和随机数. 时间戳和随机数的生成函数不会出错, 唯独主机名有可能获取失败. 在目前的代码实现中, 如果主机名获取失败, substrOfHostName 为 NULL, 那 generate() 函数会返回类似 "null-16723733647-83Ab3uK6" 这样的数据. 如果主机名获取失败, substrOfHostName 为空字符串, 那 generate() 函数会返回类似 "-16723733647-83Ab3uK6" 这样的数据.
在异常情况下, 返回上面两种特殊的 ID 数据格式, 这样的做法是否合理呢? 这个其实很难讲, 要看具体的业务是怎么设计的. 不过, 我更倾向于明确地将异常告知调用者. 所以, 这里最好是抛出受检异常, 而非特殊值.
按照这个设计思路, 对 generate() 函数进行重构. 重构之后的代码如下所示:
public String generate() throws IdGenerationFailureException {
String substrOfHostName = getLastFiledOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
2
3
4
5
6
7
8
9
10
11
12
# 2.重构getLastFiledOfHostName()函数
对于 getLastFiledOfHostName() 函数, 是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志), 还是应该将异常继续往上抛出? 如果往上抛出的话, 是直接把 UnknownHostException 异常原封不动地抛出, 还是封装成新的异常抛出?
private String getLastFiledOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
2
3
4
5
6
7
8
9
10
现在的处理方式是当主机名获取失败的时候, getLastFiledOfHostName() 函数返回 NULL 值. 前面讲过, 是返回 NULL 值还是异常对象, 要看获取不到数据是正常行为, 还是异常行为. 获取主机名失败会影响后续逻辑的处理, 并不是期望的, 所以它是一种异常行为. 这里最好是抛出异常, 而非返回 NULL 值.
至于是直接将 UnknownHostException 抛出, 还是重新封装成新的异常抛出, 要看函数跟异常是否有业务相关性. getLastFiledOfHostName() 函数用来获取主机名的最后一个字段, UnknownHostException 异常表示主机名获取失败, 两者算是业务相关, 所以可以直接将 UnknownHostException 抛出, 不需要重新包裹成新的异常.
按照上面的设计思路, 对 getLastFiledOfHostName() 函数进行重构. 重构后的代码如下所示:
private String getLastFiledOfHostName() throws UnknownHostException {
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
2
3
4
5
6
getLastFiledOfHostName() 函数修改之后, generate() 函数也要做相应的修改. 需要在 generate() 函数中, 捕获 getLastFiledOfHostName() 抛出的 UnknownHostException 异常. 当捕获到这个异常之后, 应该怎么处理呢?
按照之前的分析, ID 生成失败的时候, 需要明确地告知调用者. 所以不能在 generate() 函数中, 将 UnknownHostException 这个异常吞掉. 那应该原封不动地抛出, 还是封装成新的异常抛出呢?
选择后者. 在 generate() 函数中, 需要捕获 UnknownHostException 异常, 并重新包裹成新的异常 IdGenerationFailureException 往上抛出. 之所以这么做, 有下面三个原因.
- 调用者在使用 generate() 函数的时候, 只需要知道它生成的是随机唯一 ID, 并不关心 ID 是如何生成的. 也就说是, 这是依赖抽象而非实现编程. 如果 generate() 函数直接抛出 UnknownHostException 异常, 实际上是暴露了实现细节.
- 从代码封装的角度来讲, 不希望将 UnknownHostException 这个比较底层的异常, 暴露给更上层的代码, 也就是调用 generate() 函数的代码. 而且调用者拿到这个异常的时候, 并不能理解这个异常到底代表了什么, 也不知道该如何处理.
- UnknownHostException 异常跟 generate() 函数, 在业务概念上没有相关性.
按照上面的设计思路, 对 generate() 的函数再次进行重构. 重构后的代码如下所示:
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFiledOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
2
3
4
5
6
7
8
9
10
11
12
13
# 3.重构getLastSubstrSplittedByDot()函数
对于 getLastSubstrSplittedByDot(String hostName) 函数, 如果 hostName 为 NULL 或者空字符串, 这个函数应该返回什么?
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
2
3
4
5
6
理论上讲, 参数传递的正确性应该有程序员来保证, 无需做 NULL 值或者空字符串的判断和特殊处理. 调用者本不应该把 NULL 值或者空字符串传递给 getLastSubstrSplittedByDot() 函数. 如果传递了, 那就是 code bug, 需要修复. 但话说回来, 谁也保证不了程序员就一定不会传递 NULL 值或者空字符串. 那到底该不该做 NULL 值或空字符串的判断呢?
如果函数是 private 类私有的, 只在类内部被调用, 完全在你自己的掌控之下, 自己保证在调用这个 private 函数的时候, 不要传递 NULL 值或空字符串就可以了. 所以可以不在 private 函数中做 NULL 值或空字符串的判断. 如果函数是 public 的, 无法掌控会被谁调用以及如何调用(有可能某个同事一时疏忽, 传递进了 NULL 值, 这种情况也是存在的), 为了尽可能提高代码的健壮性, 最好是在 public 函数中做 NULL 值或空字符串的判断.
那你可能会说, getLastSubstrSplittedByDot() 是 protected 的, 既不是 private 函数, 也不是 public 函数, 那要不要做 NULL 值或空字符串的判断呢?
之所以将它设置为 protected, 是为了方便写单元测试. 不过, 单元测试可能要测试一些 corner case, 比如输入是 NULL 值或者空字符串的情况. 所以最好也加上 NULL 值或空字符串的判断逻辑. 虽然加上有些冗余, 但多加些检验总归不会错的.
按照这个设计思路, 对 getLastSubstrSplittedByDot() 函数进行重构. 重构之后的代码如下所示:
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw IllegalArgumentException("..."); //运行时异常
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
2
3
4
5
6
7
8
9
按照上面讲的, 在使用这个函数的时候, 自己也要保证不传递 NULL 值或者空字符串进去. 所以, getLastFiledOfHostName() 函数的代码也要作相应的修改. 修改之后的代码如下所示:
private String getLastFiledOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) { // 此处做判断
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
2
3
4
5
6
7
8
9
# 4.重构generateRandomAlphameric()函数
对于 generateRandomAlphameric(int length) 函数, 如果 length < 0 或 length = 0, 这个函数应该返回什么?
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
先来看 length < 0 的情况. 生成一个长度为负值的随机字符串是不符合常规逻辑的, 是一种异常行为. 所以, 当传入的参数 length < 0 的时候, 抛出 IllegalArgumentException 异常.
再来看 length = 0 的情况. length = 0 是否是异常行为呢? 这就看自己怎么定义了. 既可以把它定义为一种异常行为, 抛出 IllegalArgumentException 异常, 也可以把它定义为一种正常行为, 让函数在入参 length = 0 的情况下, 直接返回空字符串. 不管选择哪种处理方式, 最关键的一点是, 要在函数注释中, 明确告知 length = 0 的情况下, 会返回什么样的数据.
# 5.重构之后的RandomIdGenerator代码
对 RandomIdGenerator 类中各个函数异常情况处理代码的重构, 到此就结束了. 为了方便查看, 把重构之后的代码, 重新整理之后贴在这里了.
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFiledOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("...", e);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastFiledOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException("...");
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
if (length <= 0) {
throw new IllegalArgumentException("...");
}
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
对比第 34 节课最初小王的 IdGenerator 代码和最终的 RandomIdGenerator 代码, 它们一个是"能用", 一个是"好用", 天壤之别. 作为一名程序员, 起码对代码要有追求啊, 不然跟咸鱼有啥区别!
# 38-总结回顾面向对象,设计原则,编程规范,重构技巧等知识点
到今天为止, 设计原则和思想已经全部讲完了, 其中包括: 面向对象, 设计原则, 规范与重构三个模块的内容. 除此之外, 还学习了贯穿整个专栏的代码质量评判标准. 下面马上就要进入设计模式内容的学习了. 在此之前, 先来总结回顾一下已经学过的所有知识点.

# 1.代码质量评判标准
**如何评价代码质量的高低? **
代码质量的评价有很强的主观性, 描述代码质量的词汇也有很多, 比如可读性, 可维护性, 灵活, 优雅, 简洁. 这些词汇是从不同的维度去评价代码质量的. 它们之间有互相作用, 并不是独立的, 比如, 代码的可读性好, 可扩展性好就意味着代码的可维护性好. 代码质量高低是一个综合各种因素得到的结论. 并不能通过单一维度去评价一段代码的好坏.
**最常用的评价标准有哪几个? **
最常用到几个评判代码质量的标准有: 可维护性, 可读性, 可扩展性, 灵活性, 简洁性, 可复用性, 可测试性. 其中, 可维护性, 可读性, 可扩展性又是提到最多的, 最重要的三个评价标准.
**如何才能写出高质量的代码? **
要写出高质量代码, 就需要掌握一些更加细化, 更加能落地的编程方法论, 这就包含面向对象设计思想, 设计原则, 设计模式, 编码规范, 重构技巧等.

# 2.面向对象
# (1)面向对象概述
现在, 主流的编程范式或者编程风格有三种, 它们分别是面向过程, 面向对象和函数式编程. 面向对象这种编程风格又是这其中最主流的. 现在比较流行的编程语言大部分都是面向对象编程语言. 大部分项目也都是基于面向对象编程风格开发的. 面向对象编程因为其具有丰富的特性(封装, 抽象, 继承, 多态), 可以实现很多复杂的设计思路, 是很多设计原则, 设计模式编码实现的基础.
# (2)面向对象四大特性
封装也叫作信息隐藏或者数据访问保护. 类通过暴露有限的访问接口, 授权外部仅能通过类提供的方法来访问内部信息或者数据. 它需要编程语言提供权限访问控制语法来支持, 例如 Java 中的 private, protected, public 关键字. 封装特性存在的意义, 一方面是保护数据不被随意修改, 提高代码的可维护性; 另一方面是仅暴露有限的必要接口, 提高类的易用性.
如果说封装主要讲如何隐藏信息, 保护数据, 那抽象就是讲如何隐藏方法的具体实现, 让使用者只需要关心方法提供了哪些功能, 不需要知道这些功能是如何实现的. 抽象可以通过接口类或者抽象类来实现. 抽象存在的意义, 一方面是修改实现不需要改变定义; 另一方面, 它也是处理复杂系统的有效手段, 能有效地过滤掉不必要关注的信息.
继承用来表示类之间的 is-a 关系, 分为两种模式: 单继承和多继承. 单继承表示一个子类只继承一个父类, 多继承表示一个子类可以继承多个父类. 为了实现继承这个特性, 编程语言需要提供特殊的语法机制来支持. 继承主要是用来解决代码复用的问题.
多态是指子类可以替换父类, 在实际的代码运行过程中, 调用子类的方法实现. 多态这种特性也需要编程语言提供特殊的语法机制来实现, 比如继承, 接口类, duck-typing. 多态可以提高代码的扩展性和复用性, 是很多设计模式, 设计原则, 编程技巧的代码实现基础.
# (3)面向对象VS面向过程
面向对象编程相比面向过程编程的优势主要有三个.
- 对于大规模复杂程序的开发, 程序的处理流程并非单一的一条主线, 而是错综复杂的网状结构. 面向对象编程比起面向过程编程, 更能应对这种复杂类型的程序开发.
- 面向对象编程相比面向过程编程, 具有更加丰富的特性(封装, 抽象, 继承, 多态). 利用这些特性编写出来的代码, 更加易扩展, 易复用, 易维护.
- 从编程语言跟机器打交道方式的演进规律中, 可以总结出: 面向对象编程语言比起面向过程编程语言, 更加人性化, 更加高级, 更加智能.
面向对象编程一般使用面向对象编程语言来进行, 但是, 不用面向对象编程语言, 照样可以进行面向对象编程. 反过来讲, 即便使用面向对象编程语言, 写出来的代码也不一定是面向对象编程风格的, 也有可能是面向过程编程风格的.
面向对象和面向过程两种编程风格并不是非黑即白, 完全对立的. 在用面向对象编程语言开发的软件中, 面向过程风格的代码并不少见, 甚至在一些标准的开发库(比如 JDK, Apache Commons, Google Guava)中, 也有很多面向过程风格的代码.
不管使用面向过程还是面向对象哪种风格来写代码, 最终的目的还是写出易维护, 易读, 易复用, 易扩展的高质量代码. 只要我们能避免面向过程编程风格的一些弊端, 控制好它的副作用, 在掌控范围内为我们所用, 就大可不用避讳在面向对象编程中写面向过程风格的代码.
# (4)面向对象分析,设计与编程
面向对象分析(OOA), 面向对象设计(OOD), 面向对象编程(OOP), 是面向对象开发的三个主要环节. 简单点讲, 面向对象分析就是要搞清楚做什么, 面向对象设计就是要搞清楚怎么做, 面向对象编程就是将分析和设计的的结果翻译成代码的过程.
需求分析的过程实际上是一个不断迭代优化的过程. 不要试图一下就给出一个完美的解决方案, 而是先给出一个粗糙的, 基础的方案, 有一个迭代的基础, 然后再慢慢优化. 这样一个思考过程能摆脱无从下手的窘境.
面向对象设计和实现要做的事情就是把合适的代码放到合适的类中. 至于到底选择哪种划分方法, 判定的标准是让代码尽量地满足 "松耦合, 高内聚", 单一职责, 对扩展开放对修改关闭等之前讲到的各种设计原则和思想, 尽量地做到代码可复用, 易读, 易扩展, 易维护.
面向对象分析的产出是详细的需求描述. 面向对象设计的产出是类. 在面向对象设计这一环节中, 将需求描述转化为具体的类的设计. 这个环节的工作可以拆分为下面四个部分.
划分职责进而识别出有哪些类
根据需求描述, 把其中涉及的功能点, 一个一个罗列出来, 然后再去看哪些功能点职责相近, 操作同样的属性, 可否归为同一个类.
定义类及其属性和方法
识别出需求描述中的动词, 作为候选的方法, 再进一步过滤筛选出真正的方法, 把功能点中涉及的名词, 作为候选属性, 然后同样再进行过滤筛选.
定义类与类之间的交互关系
UML 统一建模语言中定义了六种类之间的关系. 它们分别是: 泛化, 实现, 关联, 聚合, 组合, 依赖. 从更加贴近编程的角度, 对类与类之间的关系做了调整, 保留了四个关系: 泛化, 实现, 组合, 依赖.
将类组装起来并提供执行入口
要将所有的类组装在一起, 提供一个执行入口. 这个入口可能是一个 main() 函数, 也可能是一组给外部用的 API 接口. 通过这个入口, 能触发整个代码跑起来.
# (5)接口VS抽象类
抽象类不允许被实例化, 只能被继承. 它可以包含属性和方法. 方法既可以包含代码实现, 也可以不包含代码实现. 不包含代码实现的方法叫作抽象方法. 子类继承抽象类, 必须实现抽象类中的所有抽象方法. 接口不能包含属性(Java 可以定义静态常量), 只能声明方法, 方法不能包含代码实现(Java8 以后可以有默认实现). 类实现接口的时候, 必须实现接口中声明的所有方法.
抽象类是对成员变量和方法的抽象, 是一种 is-a 关系, 是为了解决代码复用问题. 接口仅仅是对方法的抽象, 是一种 has-a 关系, 表示具有某一组行为特性, 是为了解决解耦问题, 隔离接口和具体的实现, 提高代码的扩展性.
什么时候该用抽象类? 什么时候该用接口? 实际上, 判断的标准很简单. 如果要表示一种 is-a 的关系, 并且是为了解决代码复用问题, 就用抽象类; 如果要表示一种 has-a 关系, 并且是为了解决抽象而非代码复用问题, 那就用接口.
# (6)基于接口而非实现编程
应用这条原则, 可以将接口和实现相分离, 封装不稳定的实现, 暴露稳定的接口. 上游系统面向接口而非实现编程, 不依赖不稳定的实现细节, 这样当实现发生变化的时候, 上游系统的代码基本上不需要做改动, 以此来降低耦合性, 提高扩展性.
实际上, "基于接口而非实现编程" 这条原则的另一个表述方式是, "基于抽象而非实现编程". 后者的表述方式其实更能体现这条原则的设计初衷. 在软件开发中, 最大的挑战之一就是需求的不断变化, 这也是考验代码设计好坏的一个标准.
越抽象, 越顶层, 越脱离具体某一实现的设计, 越能提高代码的灵活性, 越能应对未来的需求变化. 好的代码设计, 不仅能应对当下的需求, 而且在将来需求发生变化的时候, 仍然能够在不破坏原有代码设计的情况下灵活应对. 而抽象就是提高代码扩展性, 灵活性, 可维护性最有效的手段之一.
# (7)多用组合少用继承
**为什么不推荐使用继承? **
继承是面向对象的四大特性之一, 用来表示类之间的 is-a 关系, 可以解决代码复用的问题. 虽然继承有诸多作用, 但继承层次过深, 过复杂, 也会影响到代码的可维护性. 在这种情况下, 应该尽量少用, 甚至不用继承.
**组合相比继承有哪些优势? **
继承主要有三个作用: 表示 is-a 关系, 支持多态特性, 代码复用. 而这三个作用都可以通过组合, 接口, 委托三个技术手段来达成. 除此之外, 利用组合还能解决层次过深, 过复杂的继承关系影响代码可维护性的问题.
**如何判断该用组合还是继承? **
尽管鼓励多用组合少用继承, 但组合也并不是完美的, 继承也并非一无是处. 在实际的项目开发中, 还是要根据具体的情况, 来选择该用继承还是组合. 如果类之间的继承结构稳定, 层次比较浅, 关系不复杂, 就可以大胆地使用继承. 反之, 就尽量使用组合来替代继承. 除此之外, 还有一些设计模式, 特殊的应用场景, 会固定使用继承或者组合.
# (8)贫血模型VS充血模型
平时做 Web 项目的业务开发, 大部分都是基于贫血模型的 MVC 三层架构, 在专栏中把它称为传统的开发模式. 之所以称之为"传统", 是相对于新兴的基于充血模型的 DDD 开发模式来说的. 基于贫血模型的传统开发模式, 是典型的面向过程的编程风格. 相反, 基于充血模型的 DDD 开发模式, 是典型的面向对象的编程风格.
不过, DDD 也并非银弹. 对于业务不复杂的系统开发来说, 基于贫血模型的传统开发模式简单够用, 基于充血模型的 DDD 开发模式有点大材小用, 无法发挥作用. 相反, 对于业务复杂的系统开发来说, 基于充血模型的 DDD 开发模式, 因为前期需要在设计上投入更多时间和精力, 来提高代码的复用性和可维护性, 所以相比基于贫血模型的开发模式, 更加有优势.
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比, 主要区别在 Service 层. 在基于充血模型的开发模式下, 将部分原来在 Service 类中的业务逻辑移动到了一个充血的 Domain 领域模型中, 让 Service 类的实现依赖这个 Domain 类. 不过, Service 类并不会完全移除, 而是负责一些不适合放在 Domain 类中的功能. 比如, 负责与 Repository 层打交道, 跨领域模型的业务聚合功能, 幂等事务等非功能性的工作.
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比, Controller 层和 Repository 层的代码基本上相同. 这是因为, Repository 层的 Entity 生命周期有限, Controller 层的 VO 只是单纯作为一种 DTO. 两部分的业务逻辑都不会太复杂. 业务逻辑主要集中在 Service 层. 所以, Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的.

# 3.设计原则
# (1)SOLID原则:SRP单一职责原则
一个类只负责完成一个职责或者功能. 单一职责原则通过避免设计大而全的类, 避免将不相关的功能耦合在一起, 来提高类的内聚性. 同时, 类职责单一, 类依赖的和被依赖的其他类也会变少, 减少了代码的耦合性, 以此来实现代码的高内聚, 松耦合. 但是, 如果拆分得过细, 实际上会适得其反, 反倒会降低内聚性, 也会影响代码的可维护性.
不同的应用场景, 不同阶段的需求背景, 不同的业务层面, 对同一个类的职责是否单一, 可能会有不同的判定结果. 实际上, 一些侧面的判断指标更具有指导意义和可执行性, 比如, 出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数, 函数或者属性过多;
- 类依赖的其他类过多或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性.
# (2)SOLID原则:OCP开闭原则
**如何理解"对扩展开放, 修改关闭"? **
添加一个新的功能, 应该是通过在已有代码基础上扩展代码(新增模块, 类, 方法, 属性等), 而非修改已有代码(修改模块, 类, 方法, 属性等)的方式来完成. 关于定义, 有两点要注意. 第一点是, 开闭原则并不是说完全杜绝修改, 而是以最小的修改代码的代价来完成新功能的开发. 第二点是, 同样的代码改动, 在粗代码粒度下, 可能被认定为 "修改"; 在细代码粒度下, 可能又被认定为"扩展".
**如何做到"对扩展开放, 修改关闭"? **
要时刻具备扩展意识, 抽象意识, 封装意识. 在写代码的时候, 要多花点时间思考一下, 这段代码未来可能有哪些需求变更, 如何设计代码结构, 事先留好扩展点, 以便在未来需求变更的时候, 在不改动代码整体结构, 做到最小代码改动的情况下, 将新的代码灵活地插入到扩展点上.
很多设计原则, 设计思想, 设计模式, 都是以提高代码的扩展性为最终目的的. 特别是 23 种经典设计模式, 大部分都是为了解决代码的扩展性问题而总结出来的, 都是以开闭原则为指导原则的. 最常用来提高代码扩展性的方法有: 多态, 依赖注入, 基于接口而非实现编程, 以及大部分的设计模式(比如, 装饰, 策略, 模板, 职责链, 状态).
# (3)SOLID原则:LSP里式替换原则
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方, 并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏.
里式替换原则是用来指导继承关系中子类该如何设计的一个原则. 理解里式替换原则, 最核心的就是理解 "design by contract, 按照协议来设计" 这几个字. 父类定义了函数的 "约定"(或者叫协议), 那子类可以改变函数的内部实现逻辑, 但不能改变函数的原有 "约定". 这里的 "约定" 包括: 函数声明要实现的功能; 对输入, 输出, 异常的约定; 甚至包括注释中所罗列的任何特殊说明.
理解这个原则, 还要弄明白, 里式替换原则跟多态的区别. 虽然从定义描述和代码实现上来看, 多态和里式替换有点类似, 但它们关注的角度是不一样的. 多态是面向对象编程的一大特性, 也是面向对象编程语言的一种语法. 它是一种代码实现的思路. 而里式替换是一种设计原则, 用来指导继承关系中子类该如何设计, 子类的设计要保证在替换父类的时候, 不改变原有程序的逻辑及不破坏原有程序的正确性.
# (4)SOLID原则:ISP接口隔离原则
接口隔离原则的描述是: 客户端不应该强迫依赖它不需要的接口. 其中的 "客户端", 可以理解为接口的调用者或者使用者. 理解 "接口隔离原则" 的重点是理解其中的 "接口" 二字. 这里有三种不同的理解.
如果把 "接口" 理解为一组接口集合, 可以是某个微服务的接口, 也可以是某个类库的接口等. 如果部分接口只被部分调用者使用, 就需要将这部分接口隔离出来, 单独给这部分调用者使用, 而不强迫其他调用者也依赖这部分不会被用到的接口.
如果把 "接口" 理解为单个 API 接口或函数, 部分调用者只需要函数中的部分功能, 那就需要把函数拆分成粒度更细的多个函数, 让调用者只依赖它需要的那个细粒度函数.
如果把 "接口" 理解为 OOP 中的接口, 也可以理解为面向对象编程语言中的接口语法. 那接口的设计要尽量单一, 不要让接口的实现类和调用者, 依赖不需要的接口函数.
单一职责原则针对的是模块, 类, 接口的设计. 接口隔离原则相对于单一职责原则, 一方面更侧重于接口的设计, 另一方面它的思考的角度也是不同的. 接口隔离原则提供了一种判断接口的职责是否单一的标准: 通过调用者如何使用接口来间接地判定. 如果调用者只使用部分接口或接口的部分功能, 那接口的设计就不够职责单一.
# (5)SOLID原则:DIP依赖倒置原则
控制反转 : ** 实际上, 控制反转是一个比较笼统的设计思想**, 并不是一种具体的实现方法, 一般用来指导框架层面的设计. 这里所说的 "控制" 指的是对程序执行流程的控制, 而 "反转"指的是在没有使用框架之前, 程序员自己控制整个程序的执行. 在使用框架之后, 整个程序的执行流程通过框架来控制. 流程的控制权从程序员 "反转" 给了框架.
依赖注入 : ** 依赖注入和控制反转恰恰相反, 它是一种具体的编码技巧**. 不通过 new 的方式在类内部创建依赖类的对象, 而是将依赖的类对象在外部创建好之后, 通过构造函数, 函数参数等方式传递(或"注入")给类来使用.
依赖注入框架 : ** 通过依赖注入框架提供的扩展点, 简单配置一下所有需要的类及其类与类之间的依赖关系, 就可以实现由框架来自动创建对象, 管理对象的生命周期, 依赖注入等原本需要程序员来做的事情**.
依赖反转原则 **: ** 依赖反转原则也叫作依赖倒置原则. 这条原则跟控制反转有点类似, 主要用来指导框架层面的设计. 高层模块不依赖低层模块, 它们共同依赖同一个抽象. 抽象不需要依赖具体实现细节, 具体实现细节依赖抽象.
# (6)KISS,YAGNI原则
KISS 原则的中文描述是: 尽量保持简单. KISS 原则是保持代码可读和可维护的重要手段. KISS 原则中的 "简单" 并不是以代码行数来考量的. 代码行数越少并不代表代码越简单, 还要考虑逻辑复杂度, 实现难度, 代码的可读性等. 而且本身就复杂的问题, 用复杂的方法解决, 也并不违背 KISS 原则. 除此之外, 同样的代码, 在某个业务场景下满足 KISS 原则, 换一个应用场景可能就不满足了.
对于如何写出满足 KISS 原则的代码, 我总结了下面几条指导原则:
- 不要使用同事可能不懂的技术来实现代码;
- 不要重复造轮子, 善于使用已经有的工具类库;
- 不要过度优化.
YAGNI 原则的英文全称是: You Ain’t Gonna Need It. 直译就是: 你不会需要它. 这条原则也算是万金油了. 当用在软件开发中的时候, 它的意思是: 不要去设计当前用不到的功能; 不要去编写当前用不到的代码. 实际上, 这条原则的核心思想就是: 不要做过度设计.
YAGNI 原则跟 KISS 原则并非一回事儿. KISS 原则讲的是 "如何做" 的问题(尽量保持简单), 而 YAGNI 原则说的是 "要不要做" 的问题(当前不需要的就不要做).
# (7)DRY原则
DRY 原则中文描述是: 不要重复自己, 将它应用在编程中, 可以理解为: 不要写重复的代码.
专栏中讲到了三种代码重复的情况: 实现逻辑重复, 功能语义重复, 代码执行重复. 实现逻辑重复, 但功能语义不重复的代码, 并不违反 DRY 原则. 实现逻辑不重复, 但功能语义重复的代码, 也算是违反 DRY 原则. 而代码执行重复也算是违反 DRY 原则.
除此之外, 还讲到了提高代码复用性的一些手段, 包括: 减少代码耦合, 满足单一职责原则, 模块化, 业务与非业务逻辑分离, 通用代码下沉, 继承, 多态, 抽象, 封装, 应用模板等设计模式. 复用意识也非常重要. 在设计每个模块, 类, 函数的时候, 要像设计一个外部 API 一样去思考它的复用性.
在第一次写代码的时候, 如果当下没有复用的需求, 而未来的复用需求也不是特别明确, 并且开发可复用代码的成本比较高, 那就不需要考虑代码的复用性. 在之后开发新的功能的时候, 发现可以复用之前写的这段代码, 那就重构这段代码, 让其变得更加可复用.
相比于代码的可复用性, DRY 原则适用性更强些. 可以不写可复用的代码, 但一定不能写重复的代码.
# (8)LOD原则
**如何理解"高内聚, 松耦合"? **
"高内聚, 松耦合" 是一个非常重要的设计思想, 能够有效提高代码的可读性和可维护性, 缩小功能改动导致的代码改动范围. "高内聚" 用来指导类本身的设计, "松耦合" 用来指导类与类之间依赖关系的设计. 所谓高内聚, 就是指相近的功能应该放到同一个类中, 不相近的功能不要放到同一类中. 相近的功能往往会被同时修改, 放到同一个类中, 修改会比较集中. 所谓 "松耦合" 指的是, 在代码中, 类与类之间的依赖关系简单清晰. 即使两个类有依赖关系, 一个类的代码改动也不会或者很少导致依赖类的代码改动.
**如何理解"迪米特法则"? **
迪米特法则的描述为: 不该有直接依赖关系的类之间, 不要有依赖; 有依赖关系的类之间, 尽量只依赖必要的接口. 迪米特法则是希望减少类之间的耦合, 让类越独立越好. 每个类都应该少了解系统的其他部分. 一旦发生变化, 需要了解这一变化的类就会比较少.

# 4.规范与重构
# (1)重构概述
**重构的目的: 为什么重构(why)? **
对于项目来言, 重构可以保持代码质量持续处于一个可控状态, 不至于腐化到无可救药的地步. 对于个人而言, 重构非常锻炼一个人的代码能力, 并且是一件非常有成就感的事情. 它是我们学习的经典设计思想, 原则, 模式, 编程规范等理论知识的练兵场.
**重构的对象: 重构什么(what)? **
按照重构的规模, 可以将重构大致分为大规模高层次的重构和小规模低层次的重构. 大规模高层次重构包括对代码分层, 模块化, 解耦, 梳理类之间的交互关系, 抽象复用组件等等. 这部分工作利用的更多的是比较抽象, 比较顶层的设计思想, 原则, 模式. 小规模低层次的重构包括规范命名, 注释, 修正函数参数过多, 消除超大类, 提取重复代码等编程细节问题, 主要是针对类, 函数级别的重构. 小规模低层次的重构更多的是利用编码规范这一理论知识.
**重构的时机: 什么时候重构(when)? **
一定要建立持续重构意识, 把重构作为开发必不可少的部分融入到开发中, 而不是等到代码出现很大问题的时候, 再大刀阔斧地重构.
**重构的方法: 如何重构(how)? **
大规模高层次的重构难度比较大, 需要有组织, 有计划地进行, 分阶段地小步快跑, 时刻保持代码处于一个可运行的状态. 而小规模低层次的重构, 因为影响范围小, 改动耗时短, 所以只要你愿意并且有时间, 随时随地都可以去做.
# (2)单元测试
**什么是单元测试? **
单元测试是代码层面的测试, 用于测试 "自己" 编写的代码的逻辑正确性. 单元测试顾名思义是测试一个 "单元", 这个 "单元" 一般是类或函数, 而不是模块或者系统.
**为什么要写单元测试? **
单元测试能有效地发现代码中的 Bug, 代码设计上的问题. 写单元测试的过程本身就是代码重构的过程. 单元测试是对集成测试的有力补充, 能帮助我们快速熟悉代码, 是 TDD 可落地执行的折中方案.
**如何编写单元测试? **
写单元测试就是针对代码设计覆盖各种输入, 异常, 边界条件的测试用例, 并将其翻译成代码的过程. 可以利用一些测试框架来简化测试代码的编写. 对于单元测试, 需要建立以下正确的认知:
- 编写单元测试尽管繁琐, 但并不是太耗时;
- 可以稍微放低单元测试的质量要求;
- 覆盖率作为衡量单元测试好坏的唯一标准是不合理的;
- 写单元测试一般不需要了解代码的实现逻辑;
- 单元测试框架无法测试多半是代码的可测试性不好.
**单元测试为何难落地执行? **
一方面, 写单元测试本身比较繁琐, 技术挑战不大, 很多程序员不愿意去写. 另一方面, 国内研发比较偏向 "快糙猛", 容易因为开发进度紧, 导致单元测试的执行虎头蛇尾, 最后没有建立对单元测试的正确认识, 觉得可有可无, 单靠督促很难执行得很好.
# (3)代码的可测试性
**什么是代码的可测试性? **
粗略地讲, 所谓代码的可测试性, 就是针对代码编写单元测试的难易程度. 对于一段代码, 如果很难为其编写单元测试, 或者单元测试写起来很费劲, 需要依靠单元测试框架很高级的特性, 那往往就意味着代码设计得不够合理, 代码的可测试性不好.
编写可测试性代码的最有效手段
依赖注入是编写可测试性代码的最有效手段. 通过依赖注入, 在编写单元测试代码的时候, 可以通过 mock 的方法将不可控的依赖变得可控, 这也是在编写单元测试的过程中最有技术挑战的地方. 除了 mock 方式, 还可以利用二次封装来解决某些代码行为不可控的情况.
常见的 Anti-Patterns
典型的, 常见的测试不友好的代码有下面这 5 种:
- 代码中包含未决行为逻辑;
- 滥用可变全局变量;
- 滥用静态方法;
- 使用复杂的继承关系;
- 高度耦合的代码.
# (4)大型重构:解耦
**"解耦"为何如此重要? **
过于复杂的代码往往在可读性, 可维护性上都不友好. 解耦, 保证代码松耦合, 高内聚, 是控制代码复杂度的有效手段. 如果代码高内聚, 松耦合, 也就是意味着, 代码结构清晰, 分层, 模块化合理, 依赖关系简单, 模块或类之间的耦合小, 那代码整体的质量就不会差.
**代码是否需要"解耦"? **
间接的衡量标准有很多, 比如: 改动一个模块或类的代码受影响的模块或类是否有很多, 改动一个模块或者类的代码依赖的模块或者类是否需要改动, 代码的可测试性是否好等等. 直接的衡量标准是把模块与模块之间及其类与类之间的依赖关系画出来, 根据依赖关系图的复杂性来判断是否需要解耦重构.
**如何给代码"解耦"? **
给代码解耦的方法有: 封装与抽象, 中间层, 模块化, 以及一些其他的设计思想与原则, 比如: 单一职责原则, 基于接口而非实现编程, 依赖注入, 多用组合少用继承, 迪米特法则. 当然, 还有一些设计模式, 比如观察者模式.
# (5)小型重构:编码规范
前面讲了很多设计原则, 后面还会讲到很多设计模式, 利用好它们都可以有效地改善代码的质量. 但这些知识的合理应用非常依赖个人经验, 有时候用不好会适得其反. 但是编码规范正好相反, 大部分都简单明了, 在代码的细节方面, 能立竿见影地改善质量. 除此之外, 前面也讲到, 持续低层次小规模重构依赖的基本上都是这些编码规范, 也是改善代码可读性的有效手段.
根据我自己的开发经验, 我总结罗列了 20 条我认为最应该关注, 最好用的编码规范, 分为三个大的方面: ** 命名与注释(Naming and Comments), 代码风格(Code Style), 编程技巧(Coding Tips)** .
命名与注释
- 命名的关键是能准确的达意. 对于不同作用域的命名, 可以适当的选择不同的长度, 作用域小的命名, 比如临时变量等, 可以适当的选择短一些的命名方式. 除此之外, 命名中个也可以使用一些耳熟能详的缩写.
- 借助类的信息来简化属性, 函数的命名, 利用函数的信息来简化函数参数的命名.
- 命名要可读, 可搜索. 不要使用生僻的, 不好读的英文单词来命名. 除此之外, 命名要符合项目的统一规范, 也不要用些反直觉的命名.
- 接口有两种命名方式. 一种是在接口中带前缀 "I", 另一种是在接口的实现类中带后缀 "Impl". 两种命名方式都可以, 关键是要在项目中统一. 对于抽象类的命名, 更倾向于带有前缀 "Abstract".
- 注释的目的就是让代码更容易看懂, 只要符合这个要求, 就可以写. 总结一下的话, 注释主要包含这样三个方面的内容: 做什么, 为什么, 怎么做. 对于一些复杂的类和接口, 可能还需要写明 "如何用".
- 注释本身有一定的维护成本, 所以并非越多越好. 类和函数一定要写注释, 而且要写的尽可能全面详细些, 而函数内部的注释会相对少一些, 一般都是靠好的命名和提炼函数, 解释性变量, 总结性注释来做到代码易读.
代码风格
代码风格都没有对错和优劣之分, 不同的编程语言风格都不太一样, 只要能在团队, 项目中统一即可, 不过, 最好能跟业内推荐的风格, 开源项目的代码风格相一致. 所以这里就不展开罗列了, 你可以对照着自己熟悉的编程语言的代码风格, 自己复习一下.
编程技巧
- 将复杂的逻辑提炼拆分成函数和类;
- 通过拆分成多个函数的方式来处理参数过多的情况;
- 通过将参数封装为对象来处理参数过多的情况;
- 函数中不要使用参数来做代码执行逻辑的控制;
- 移除过深的嵌套层次, 方法包括: 去掉多余的 if 或 else 语句, 使用 continue, break, return 关键字提前退出嵌套, 调整执行顺序来减少嵌套, 将部分嵌套逻辑抽象成函数;
- 用字面常量取代魔法数;
- 利用解释性变量来解释复杂表达式.
统一编码规范
除了细节的知识点之外, 最后还有一条非常重要的, 那就是, 项目, 团队, 甚至公司, 一定要制定统一的编码规范, 并且通过 Code Review 督促执行, 这对提高代码质量有立竿见影的效果.

# 39-运用学过的设计原则和思想完善之前讲的性能计数器项目(上)
前面讲了如何对一个性能计数器框架进行分析, 设计与实现, 并且实践了之前学过的一些设计原则和设计思想. 当时提到, 小步快跑, 逐步迭代是一种非常实用的开发模式. 所以, 针对这个框架的开发, 我们分多个版本来逐步完善.
前面实现了框架的第一个版本, 它只包含最基本的一些功能, 在设计与实现上还有很多不足. 所以, 接下来会针对这些不足, 继续迭代开发两个版本: 版本 2 和版本 3, 分别对应后两节的内容.
在版本 2 中, 会利用之前学过的重构方法, 对版本 1 的设计与实现进行重构, 解决版本 1 存在的设计问题, 让它满足之前学过的设计原则, 思想, 编程规范. 在版本 3 中, 再对版本 2 进行迭代, 并且完善框架的功能和非功能需求, 让其满足第 25 节课中罗列的所有需求.
# 1.回顾版本1的设计与实现
一块回顾一下版本 1 的设计与实现. 在版本 1 中, 整个框架的代码被划分为下面这几个类.
- MetricsCollector: 负责打点采集原始数据, 包括记录每次接口请求的响应时间和请求时间戳, 并调用 MetricsStorage 提供的接口来存储这些原始数据.
- MetricsStorage 和 RedisMetricsStorage: 负责原始数据的存储和读取.
- Aggregator: 是一个工具类, 负责各种统计数据的计算, 比如响应时间的最大值, 最小值, 平均值, 百分位值, 接口访问次数, tps.
- ConsoleReporter 和 EmailReporter: 相当于一个上帝类(God Class), 定时根据给定的时间区间, 从数据库中取出数据, 借助 Aggregator 类完成统计工作, 并将统计结果输出到相应的终端, 比如命令行, 邮件.
MetricCollector, MetricsStorage, RedisMetricsStorage 的设计与实现比较简单, 不是版本 2 重构的重点. 今天重点来看一下 Aggregator 和 ConsoleReporter, EmailReporter 这几个类.
**先来看一下 Aggregator 类存在的问题. **
Aggregator 类里面只有一个静态函数, 有 50 行左右的代码量, 负责各种统计数据的计算. 当要添加新的统计功能的时候, 需要修改 aggregate() 函数代码. 一旦越来越多的统计功能添加进来之后, 这个函数的代码量会持续增加, 可读性, 可维护性就变差了. 因此需要在版本 2 中对其进行重构.
public class Aggregator {
public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
double maxRespTime = Double.MIN_VALUE;
double minRespTime = Double.MAX_VALUE;
double avgRespTime = -1;
double p999RespTime = -1;
double p99RespTime = -1;
double sumRespTime = 0;
long count = 0;
for (RequestInfo requestInfo : requestInfos) {
++count;
double respTime = requestInfo.getResponseTime();
if (maxRespTime < respTime) {
maxRespTime = respTime;
}
if (minRespTime > respTime) {
minRespTime = respTime;
}
sumRespTime += respTime;
}
if (count != 0) {
avgRespTime = sumRespTime / count;
}
long tps = (long)(count / durationInMillis * 1000);
Collections.sort(requestInfos, new Comparator<RequestInfo>() {
@Override
public int compare(RequestInfo o1, RequestInfo o2) {
double diff = o1.getResponseTime() - o2.getResponseTime();
if (diff < 0.0) {
return -1;
} else if (diff > 0.0) {
return 1;
} else {
return 0;
}
}
});
if (count != 0) {
int idx999 = (int)(count * 0.999);
int idx99 = (int)(count * 0.99);
p999RespTime = requestInfos.get(idx999).getResponseTime();
p99RespTime = requestInfos.get(idx99).getResponseTime();
}
RequestStat requestStat = new RequestStat();
requestStat.setMaxResponseTime(maxRespTime);
requestStat.setMinResponseTime(minRespTime);
requestStat.setAvgResponseTime(avgRespTime);
requestStat.setP999ResponseTime(p999RespTime);
requestStat.setP99ResponseTime(p99RespTime);
requestStat.setCount(count);
requestStat.setTps(tps);
return requestStat;
}
}
public class RequestStat {
private double maxResponseTime;
private double minResponseTime;
private double avgResponseTime;
private double p999ResponseTime;
private double p99ResponseTime;
private long count;
private long tps;
// ...省略getter/setter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
**再来看一下 ConsoleReporter 和 EmailReporter 这两个类存在的问题. **
ConsoleReporter 和 EmailReporter 两个类中存在代码重复问题. 在这两个类中, 从数据库中取数据, 做统计的逻辑都是相同的, 可以抽取出来复用, 否则就违反了 DRY 原则.
整个类负责的事情比较多, 不相干的逻辑糅合在里面, 职责不够单一. 特别是显示部分的代码可能会比较复杂(比如 Email 的显示方式), 最好能将这部分显示逻辑剥离出来, 设计成一个独立的类.
除此之外, 因为代码中涉及线程操作, 并且调用了 Aggregator 的静态函数, 所以代码的可测试性也有待提高.
public class ConsoleReporter {
private MetricsStorage metricsStorage;
private ScheduledExecutorService executor;
public ConsoleReporter(MetricsStorage metricsStorage) {
this.metricsStorage = metricsStorage;
this.executor = Executors.newSingleThreadScheduledExecutor();
}
public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
long durationInMillis = durationInSeconds * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> stats = new HashMap<>();
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
stats.put(apiName, requestStat);
}
System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
Gson gson = new Gson();
System.out.println(gson.toJson(stats));
}
}, 0, periodInSeconds, TimeUnit.SECONDS);
}
}
public class EmailReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private EmailSender emailSender;
private List<String> toAddresses = new ArrayList<>();
public EmailReporter(MetricsStorage metricsStorage) {
this(metricsStorage, new EmailSender(/*省略参数*/));
}
public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
this.metricsStorage = metricsStorage;
this.emailSender = emailSender;
}
public void addToAddress(String address) {
toAddresses.add(address);
}
public void startDailyReport() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date firstTime = calendar.getTime();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> stats = new HashMap<>();
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
stats.put(apiName, requestStat);
}
// TODO: 格式化为html格式, 并且发送邮件
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# 2.针对版本1的问题进行重构
Aggregator 类和 ConsoleReporter, EmailReporter 类主要负责统计显示的工作. 前面提到, 如果把统计显示所要完成的功能逻辑细分一下, 主要包含下面 4 点:
- 根据给定的时间区间, 从数据库中拉取数据;
- 根据原始数据, 计算得到统计数据;
- 将统计数据显示到终端(命令行或邮件);
- 定时触发以上三个过程的执行.
之前的划分方法是将所有的逻辑都放到 ConsoleReporter 和 EmailReporter 这两个上帝类中, 而 Aggregator 只是一个包含静态方法的工具类. 这样的划分方法存在前面提到的一些问题, 需要对其进行重新划分.
面向对象设计中的最后一步是组装类并提供执行入口, 所以, 组装前三部分逻辑的上帝类是必须要有的. 可以将上帝类做的很轻量级, 把核心逻辑都剥离出去, 形成独立的类, 上帝类只负责组装类和串联执行流程. 这样做的好处是, 代码结构更加清晰, 底层核心逻辑更容易被复用. 按照这个设计思路, 具体的重构工作包含以下 4 个方面.
第 1 个逻辑: 根据给定时间区间, 从数据库中拉取数据. 这部分逻辑已经被封装在 MetricsStorage 类中了, 所以这部分不需要处理.
第 2 个逻辑: 根据原始数据, 计算得到统计数据. 可以将这部分逻辑移动到 Aggregator 类中. 这样 Aggregator 类就不仅仅是只包含统计方法的工具类了. 按照这个思路, 重构之后的代码如下所示:
public class Aggregator {
public Map<String, RequestStat> aggregate(
Map<String, List<RequestInfo>> requestInfos, long durationInMillis) {
Map<String, RequestStat> requestStats = new HashMap<>();
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
RequestStat requestStat = doAggregate(requestInfosPerApi, durationInMillis);
requestStats.put(apiName, requestStat);
}
return requestStats;
}
private RequestStat doAggregate(List<RequestInfo> requestInfos, long durationInMillis) {
List<Double> respTimes = new ArrayList<>();
for (RequestInfo requestInfo : requestInfos) {
double respTime = requestInfo.getResponseTime();
respTimes.add(respTime);
}
RequestStat requestStat = new RequestStat();
requestStat.setMaxResponseTime(max(respTimes));
requestStat.setMinResponseTime(min(respTimes));
requestStat.setAvgResponseTime(avg(respTimes));
requestStat.setP999ResponseTime(percentile999(respTimes));
requestStat.setP99ResponseTime(percentile99(respTimes));
requestStat.setCount(respTimes.size());
requestStat.setTps((long) tps(respTimes.size(), durationInMillis/1000));
return requestStat;
}
// 以下的函数的代码实现均省略...
private double max(List<Double> dataset) {}
private double min(List<Double> dataset) {}
private double avg(List<Double> dataset) {}
private double tps(int count, double duration) {}
private double percentile999(List<Double> dataset) {}
private double percentile99(List<Double> dataset) {}
private double percentile(List<Double> dataset, double ratio) {}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
第 3 个逻辑: 将统计数据显示到终端. 将这部分逻辑剥离出来, 设计成两个类: ConsoleViewer 类和 EmailViewer 类, 分别负责将统计结果显示到命令行和邮件中. 具体的代码实现如下所示:
public interface StatViewer {
void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills);
}
public class ConsoleViewer implements StatViewer {
public void output(
Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMills + "]");
Gson gson = new Gson();
System.out.println(gson.toJson(requestStats));
}
}
public class EmailViewer implements StatViewer {
private EmailSender emailSender;
private List<String> toAddresses = new ArrayList<>();
public EmailViewer() {
this.emailSender = new EmailSender(/*省略参数*/);
}
public EmailViewer(EmailSender emailSender) {
this.emailSender = emailSender;
}
public void addToAddress(String address) {
toAddresses.add(address);
}
public void output(
Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
// format the requestStats to HTML style.
// send it to email toAddresses.
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
第 4 个逻辑: 组装类并定时触发执行统计显示. 在将核心逻辑剥离出来之后, 这个类的代码变得更加简洁, 清晰, 只负责组装各个类(MetricsStorage, Aggegrator, StatViewer)来完成整个工作流程. 重构之后的代码如下所示:
public class ConsoleReporter {
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
private ScheduledExecutorService executor;
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
this.executor = Executors.newSingleThreadScheduledExecutor();
}
public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
long durationInMillis = durationInSeconds * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
}
}, 0L, periodInSeconds, TimeUnit.SECONDS);
}
}
public class EmailReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
public void startDailyReport() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date firstTime = calendar.getTime();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> stats = aggregator.aggregate(requestInfos, durationInMillis);
viewer.output(stats, startTimeInMillis, endTimeInMillis);
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
经过上面的重构之后, 现在再来看一下, 现在框架该如何来使用.
需要在应用启动的时候, 创建好 ConsoleReporter 对象, 并且调用它的 startRepeatedReport() 函数, 来启动定时统计并输出数据到终端. 同理, 还需要创建好 EmailReporter 对象, 并且调用它的 startDailyReport() 函数, 来启动每日统计并输出数据到制定邮件地址. 通过 MetricsCollector 类来收集接口的访问情况, 这部分收集代码会跟业务逻辑代码耦合在一起, 或者统一放到类似 Spring AOP 的切面中完成. 具体的使用代码示例如下:
public class PerfCounterTest {
public static void main(String[] args) {
MetricsStorage storage = new RedisMetricsStorage();
Aggregator aggregator = new Aggregator();
// 定时触发统计并将结果显示到终端
ConsoleViewer consoleViewer = new ConsoleViewer();
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
consoleReporter.startRepeatedReport(60, 60);
// 定时触发统计并将结果输出到邮件
EmailViewer emailViewer = new EmailViewer();
emailViewer.addToAddress("wangzheng@xzg.com");
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
emailReporter.startDailyReport();
// 收集接口访问数据
MetricsCollector collector = new MetricsCollector(storage);
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("register", 323, 12334));
collector.recordRequest(new RequestInfo("login", 23, 12434));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 3.Review版本2的设计与实现
现在, Review 一下, 针对版本 1 重构之后, 版本 2 的设计与实现.
重构之后, MetricsStorage 负责存储, Aggregator 负责统计, StatViewer(ConsoleViewer, EmailViewer)负责显示, 三个类各司其职. ConsoleReporter 和 EmailReporter 负责组装这三个类, 将获取原始数据, 聚合统计, 显示统计结果到终端这三个阶段的工作串联起来, 定时触发执行.
除此之外, MetricsStorage, Aggregator, StatViewer 三个类的设计也符合迪米特法则. 它们只与跟自己有直接相关的数据进行交互. MetricsStorage 输出的是 RequestInfo 相关数据. Aggregator 类输入的是 RequestInfo 数据, 输出的是 RequestStat 数据. StatViewer 输入的是 RequestStat 数据.
针对版本 1 和版本 2, 我画了一张它们的类之间依赖关系的对比图, 如下所示. 从图中可以看出, 重构之后的代码结构更加清晰, 有条理. 这也印证了之前提到的: 面向对象设计和实现要做的事情, 就是把合适的代码放到合适的类中.

刚刚分析了代码的整体结构和依赖关系, 现在再来具体看每个类的设计.
Aggregator 类从一个只包含一个静态函数的工具类, 变成了一个普通的聚合统计类. 现在可以通过依赖注入的方式, 将其组装进 ConsoleReporter 和 EmailReporter 类中, 这样就更加容易编写单元测试.
Aggregator 类在重构前, 所有的逻辑都集中在 aggregate() 函数内, 代码行数较多, 代码的可读性和可维护性较差. 在重构之后, 将每个统计逻辑拆分成独立的函数, aggregate() 函数变得比较单薄, 可读性提高了. 尽管要添加新的统计功能, 还是要修改 aggregate() 函数, 但现在的 aggregate() 函数代码行数很少, 结构非常清晰, 修改起来更加容易, 可维护性提高.
目前来看, Aggregator 的设计还算合理. 但如果随着更多的统计功能的加入, Aggregator 类的代码会越来越多. 这个时候, 可以将统计函数剥离出来, 设计成独立的类, 以解决 Aggregator 类的无限膨胀问题. 不过, 暂时来说没有必要这么做, 毕竟将每个统计函数独立成类, 会增加类的个数, 也会影响到代码的可读性和可维护性.
ConsoleReporter 和 EmailReporter 经过重构之后, 代码的重复问题变小了, 但仍然没有完全解决. 尽管这两个类不再调用 Aggregator 的静态方法, 但因为涉及多线程和时间相关的计算, 代码的测试性仍然不够好. 这两个问题留在下一节解决.
重点回顾
面向对象设计中的最后一步是组装类并提供执行入口, 也就是上帝类要做的事情. 这个上帝类是没办法去掉的, 但可以将上帝类做得很轻量级, 把核心逻辑都剥离出去, 下沉形成独立的类. 上帝类只负责组装类和串联执行流程. 这样做的好处是, 代码结构更加清晰, 底层核心逻辑更容易被复用.
面向对象设计和实现要做的事情, 就是把合适的代码放到合适的类中. 当要实现某个功能的时候, 不管如何设计, 所需要编写的代码量基本上是一样的, 唯一的区别就是如何将这些代码划分到不同的类中. 不同的人有不同的划分方法, 对应得到的代码结构(比如类与类之间交互等)也不尽相同.
好的设计一定是结构清晰, 有条理, 逻辑性强, 看起来一目了然, 读完之后常常有一种原来如此的感觉. 差的设计往往逻辑, 代码乱塞一通, 没有什么设计思路可言, 看起来莫名其妙, 读完之后一头雾水.
# 40-运用学过的设计原则和思想完善性能计数器项目(下)
上一节针对版本 1 存在的问题(特别是 Aggregator 类, ConsoleReporter 和 EmailReporter 类)进行了重构优化. 经过重构之后, 代码结构更加清晰, 合理, 有逻辑性. 不过, 在细节方面还是存在一些问题, 比如 ConsoleReporter, EmailReporter 类仍然存在代码重复, 可测试性差的问题. 今天就在版本 3 中持续重构这部分代码.
除此之外, 在版本 3 中, 还会继续完善框架的功能和非功能需求. 比如, 让原始数据的采集和存储异步执行, 解决聚合统计在数据量大的情况下会导致内存吃紧问题, 以及提高框架的易用性等, 让它成为一个能用且好用的框架.
# 1.代码重构优化
继承能解决代码重复的问题. 可以将 ConsoleReporter 和 EmailReporter 中的相同代码逻辑, 提取到父类 ScheduledReporter 中, 以解决代码重复问题. 按照这个思路, 重构之后的代码如下所示:
public abstract class ScheduledReporter {
protected MetricsStorage metricsStorage;
protected Aggregator aggregator;
protected StatViewer viewer;
public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ConsoleReporter 和 EmailReporter 代码重复的问题解决了, 那再来看一下代码的可测试性问题. 因为 ConsoleReporter 和 EmailReporter 的代码比较相似, 且 EmailReporter 的代码更复杂些, 所以关于如何重构来提高其可测试性, 拿 EmailReporter 来举例说明. 将重复代码提取到父类 ScheduledReporter 之后, EmailReporter 代码如下所示:
public class EmailReporter extends ScheduledReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
public void startDailyReport() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date firstTime = calendar.getTime();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
doStatAndReport(startTimeInMillis, endTimeInMillis);
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
前面提到, 之所以 EmailReporter 可测试性不好, 一方面是因为用到了线程(定时器也相当于多线程), 另一方面是因为涉及时间的计算逻辑.
实际上, 在经过上一步的重构之后, EmailReporter 中的 startDailyReport() 函数的核心逻辑已经被抽离出去了, 较复杂的, 容易出 bug 的就只剩下计算 firstTime 的那部分代码了. 可以将这部分代码继续抽离出来, 封装成一个函数, 然后单独针对这个函数写单元测试. 重构之后的代码如下所示:
public class EmailReporter extends ScheduledReporter {
// 省略其他代码...
public void startDailyReport() {
Date firstTime = trimTimeFieldsToZeroOfNextDay();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// 省略其他代码...
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
// 设置成protected而非private是为了方便写单元测试
@VisibleForTesting
protected Date trimTimeFieldsToZeroOfNextDay() {
Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTime();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
简单的代码抽离成 trimTimeFieldsToZeroOfNextDay() 函数之后, 虽然代码更加清晰了, 一眼就能从名字上知道这段代码的意图(获取当前时间的下一天的 0 点时间), 但这个函数的可测试性仍然不好, 因为它强依赖当前的系统时间. 实际上, 这个问题挺普遍的. 一般的解决方法是, 将强依赖的部分通过参数传递进来, 这有点类似之前讲的依赖注入. 按照这个思路, 再对 trimTimeFieldsToZeroOfNextDay() 函数进行重构. 重构之后的代码如下所示:
public class EmailReporter extends ScheduledReporter {
// 省略其他代码...
public void startDailyReport() {
// new Date()可以获取当前时间
Date firstTime = trimTimeFieldsToZeroOfNextDay(new Date());
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// 省略其他代码...
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
protected Date trimTimeFieldsToZeroOfNextDay(Date date) {
Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
calendar.setTime(date); // 重新设置时间
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTime();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
经过这次重构之后, trimTimeFieldsToZeroOfNextDay() 函数不再强依赖当前的系统时间, 所以非常容易对其编写单元测试.
不过, EmailReporter 类中 startDailyReport() 还是涉及多线程, 针对这个函数该如何写单元测试呢? 我的看法是, 这个函数不需要写单元测试. 为什么这么说呢? 可以回到写单元测试的初衷来分析这个问题. 单元测试是为了提高代码质量, 减少 bug. 如果代码足够简单, 简单到 bug 无处隐藏, 那就没必要为了写单元测试而写单元测试, 或者为了追求单元测试覆盖率而写单元测试. 经过多次代码重构之后, startDailyReport() 函数里面已经没有多少代码逻辑了, 所以完全没必要对它写单元测试了.
# 2.功能需求完善
经过了多个版本的迭代, 重构, 现在来重新 Review 一下, 目前的设计与实现是否已经完全满足前面提到的最初的功能需求了.
最初的功能需求描述是下面这个样子的, 来重新看一下.
我们希望设计开发一个小的框架, 能够获取接口调用的各种统计信息, 比如响应时间的最大值(max), 最小值(min), 平均值(avg), 百分位值(percentile), 接口调用次数(count), 频率(tps) 等, 并且支持将统计结果以各种显示格式(比如: JSON 格式, 网页格式, 自定义显示格式等)输出到各种终端(Console 命令行, HTTP 网页, Email, 日志文件, 自定义输出终端等), 以方便查看.
经过整理拆解之后的需求列表如下所示:
接口统计信息: 包括接口响应时间的统计信息, 以及接口调用次数的统计信息等.
统计信息的类型: max, min, avg, percentile, count, tps 等.
统计信息显示格式: JSON, HTML, 自定义显示格式.
统计信息显示终端: Console, Email, HTTP 网页, 日志, 自定义显示终端.
经过挖掘, 还得到一些隐藏的需求, 如下所示:
统计触发方式: 包括主动和被动两种. 主动表示以一定的频率定时统计数据, 并主动推送到显示终端, 比如邮件推送. 被动表示用户触发统计, 比如用户在网页中选择要统计的时间区间, 触发统计, 并将结果显示给用户.
统计时间区间: 框架需要支持自定义统计时间区间, 比如统计最近 10 分钟的某接口的 tps, 访问次数, 或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值, 最小值, 平均值等.
统计时间间隔: 对于主动触发统计, 还要支持指定统计时间间隔, 也就是多久触发一次统计显示. 比如, 每间隔 10s 统计一次接口信息并显示到命令行中, 每间隔 24 小时发送一封统计信息邮件.
版本 3 已经实现了大部分的功能, 还有以下几个小的功能点没有实现. 你可以将这些还没有实现的功能, 自己实现一下, 继续迭代出框架的第 4 个版本.
- 被动触发统计的方式, 也就是需求中提到的通过网页展示统计信息. 实际上, 这部分代码的实现也并不难. 可以复用框架现在的代码, 编写一些展示页面和提供获取统计数据的接口即可.
- 对于自定义显示终端, 比如显示数据到自己开发的监控平台, 这就有点类似通过网页来显示数据, 不过更加简单些, 只需要提供一些获取统计数据的接口, 监控平台通过这些接口拉取数据来显示即可.
- 自定义显示格式. 在框架现在的代码实现中, 显示格式和显示终端(比如 Console, Email)是紧密耦合在一起的, 比如 Console 只能通过 JSON 格式来显示统计数据, Email 只能通过某种固定的 HTML 格式显示数据, 这样的设计还不够灵活. 可以将显示格式设计成独立的类, 将显示终端和显示格式的代码分离, 让显示终端支持配置不同的显示格式.
# 3.非功能需求完善
Review 完了功能需求的完善程度, 现在再来看, 版本 3 的非功能性需求的完善程度. 前面提到, 针对这个框架的开发, 需要考虑的非功能性需求包括: 易用性, 性能, 扩展性, 容错性, 通用性. 现在就依次来看一下这几个方面.
# (1)易用性
所谓的易用性, 顾名思义, 就是框架是否好用. 框架的使用者将框架集成到自己的系统中时, 主要用到 MetricsCollector 和 EmailReporter, ConsoleReporter 这几个类. 通过 MetricsCollector 类来采集数据, 通过 EmailReporter, ConsoleReporter 类来触发主动统计数据, 显示统计结果. 示例代码如下所示:
public class PerfCounterTest {
public static void main(String[] args) {
MetricsStorage storage = new RedisMetricsStorage();
Aggregator aggregator = new Aggregator();
// 定时触发统计并将结果显示到终端
ConsoleViewer consoleViewer = new ConsoleViewer();
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
consoleReporter.startRepeatedReport(60, 60);
// 定时触发统计并将结果输出到邮件
EmailViewer emailViewer = new EmailViewer();
emailViewer.addToAddress("wangzheng@xzg.com");
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
emailReporter.startDailyReport();
// 收集接口访问数据
MetricsCollector collector = new MetricsCollector(storage);
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("register", 323, 12334));
collector.recordRequest(new RequestInfo("login", 23, 12434));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
从上面的使用示例中, 可以看出, 框架用起来还是稍微有些复杂的, 需要组装各种类, 比如需要创建 MetricsStorage 对象, Aggregator 对象, ConsoleViewer 对象, 然后注入到 ConsoleReporter 中, 才能使用 ConsoleReporter. 除此之外, 还有可能存在误用的情况, 比如把 EmailViewer 传递进了 ConsoleReporter 中. 总体上来讲, 框架的使用方式暴露了太多细节给用户, 过于灵活也带来了易用性的降低.
为了让框架用起来更加简单(能将组装的细节封装在框架中, 不暴露给框架使用者), 又不失灵活性(可以自由组装不同的 MetricsStorage 实现类, StatViewer 实现类到 ConsoleReporter 或 EmailReporter), 也不降低代码的可测试性(通过依赖注入来组装类, 方便在单元测试中 mock), 可以额外地提供一些封装了默认依赖的构造函数, 让使用者自主选择使用哪种构造函数来构造对象. 这个思路重构之后的代码如下.
public class MetricsCollector {
private MetricsStorage metricsStorage;
// 兼顾代码的易用性, 新增一个封装了默认依赖的构造函数
public MetricsCollectorB() {
this(new RedisMetricsStorage());
}
// 兼顾灵活性和代码的可测试性, 这个构造函数继续保留
public MetricsCollectorB(MetricsStorage metricsStorage) {
this.metricsStorage = metricsStorage;
}
// 省略其他代码...
}
public class ConsoleReporter extends ScheduledReporter {
private ScheduledExecutorService executor;
// 兼顾代码的易用性, 新增一个封装了默认依赖的构造函数
public ConsoleReporter() {
this(new RedisMetricsStorage(), new Aggregator(), new ConsoleViewer());
}
// 兼顾灵活性和代码的可测试性, 这个构造函数继续保留
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
super(metricsStorage, aggregator, viewer);
this.executor = Executors.newSingleThreadScheduledExecutor();
}
// 省略其他代码...
}
public class EmailReporter extends ScheduledReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
// 兼顾代码的易用性, 新增一个封装了默认依赖的构造函数
public EmailReporter(List<String> emailToAddresses) {
this(new RedisMetricsStorage(), new Aggregator(), new EmailViewer(emailToAddresses));
}
// 兼顾灵活性和代码的可测试性, 这个构造函数继续保留
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
super(metricsStorage, aggregator, viewer);
}
// 省略其他代码...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
现在, 再来看下框架如何来使用. 具体使用示例如下所示. 看起来是不是简单多了呢?
public class PerfCounterTest {
public static void main(String[] args) {
ConsoleReporter consoleReporter = new ConsoleReporter();
consoleReporter.startRepeatedReport(60, 60);
List<String> emailToAddresses = new ArrayList<>();
emailToAddresses.add("wangzheng@xzg.com");
EmailReporter emailReporter = new EmailReporter(emailToAddresses);
emailReporter.startDailyReport();
MetricsCollector collector = new MetricsCollector();
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("register", 323, 12334));
collector.recordRequest(new RequestInfo("login", 23, 12434));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
如果你足够细心, 可能已经发现, RedisMeticsStorage 和 EmailViewer 还需要另外一些配置信息才能构建成功, 比如 Redis 的地址, Email 邮箱的 POP3 服务器地址, 发送地址. 这些配置并没有在刚刚代码中体现到, 那该如何获取呢?
可以将这些配置信息放到配置文件中, 在框架启动的时候, 读取配置文件中的配置信息到一个 Configuration 单例类. RedisMetricsStorage 类和 EmailViewer 类都可以从这个 Configuration 类中获取需要的配置信息来构建自己.
# (2)性能
对于需要集成到业务系统的框架来说, 不希望框架本身代码的执行效率, 对业务系统有太多性能上的影响. 对于性能计数器这个框架来说, 一方面, 希望它是低延迟的, 也就是说, 统计代码不影响或很少影响接口本身的响应时间; 另一方面, 希望框架本身对内存的消耗不能太大.
对于性能这一点, 落实到具体的代码层面, 需要解决两个问题, 也是之前提到过的, 一个是采集和存储要异步来执行, 因为存储基于外部存储(比如 Redis), 会比较慢, 异步存储可以降低对接口响应时间的影响. 另一个是当需要聚合统计的数据量比较大的时候, 一次性加载太多的数据到内存, 有可能会导致内存吃紧, 甚至内存溢出, 这样整个系统都会瘫痪掉.
针对第一个问题, 通过在 MetricsCollector 中引入 Google Guava EventBus 来解决. 实际上, 可以把 EventBus 看作一个 "生产者-消费者" 模型或者 "发布-订阅" 模型, 采集的数据先放入内存共享队列中, 另一个线程读取共享队列中的数据, 写入到外部存储(比如 Redis)中. 具体的代码实现如下所示:
public class MetricsCollector {
private static final int DEFAULT_STORAGE_THREAD_POOL_SIZE = 20;
private MetricsStorage metricsStorage;
private EventBus eventBus;
public MetricsCollector(MetricsStorage metricsStorage) {
this(metricsStorage, DEFAULT_STORAGE_THREAD_POOL_SIZE);
}
public MetricsCollector(MetricsStorage metricsStorage, int threadNumToSaveData) {
this.metricsStorage = metricsStorage;
this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(threadNumToSaveData));
this.eventBus.register(new EventListener());
}
public void recordRequest(RequestInfo requestInfo) {
if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
return;
}
eventBus.post(requestInfo);
}
public class EventListener {
@Subscribe
public void saveRequestInfo(RequestInfo requestInfo) {
metricsStorage.saveRequestInfo(requestInfo);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
针对第二个问题, 解决的思路比较简单, 但代码实现稍微有点复杂. 当统计的时间间隔较大的时候, 需要统计的数据量就会比较大. 可以将其划分为一些小的时间区间(比如 10 分钟作为一个统计单元), 针对每个小的时间区间分别进行统计, 然后将统计得到的结果再进行聚合, 得到最终整个时间区间的统计结果. 不过, 这个思路只适合响应时间的 max, min, avg, 及其接口请求 count, tps 的统计, 对于响应时间的 percentile 的统计并不适用.
对于 percentile 的统计要稍微复杂一些, 具体的解决思路是这样子的: 分批从 Redis 中读取数据, 然后存储到文件中, 再根据响应时间从小到大利用外部排序算法来进行排序. 排序完成之后, 再从文件中读取第 count * percentile(count 表示总的数据个数, percentile 就是百分比, 99 百分位就是 0.99)个数据, 就是对应的 percentile 响应时间.
这里只给出了除了 percentile 之外的统计信息的计算代码, 如下所示. 对于 percentile 的计算, 因为代码量比较大, 留给你自己实现.
public class ScheduleReporter {
private static final long MAX_STAT_DURATION_IN_MILLIS = 10 * 60 * 1000; // 10minutes
protected MetricsStorage metricsStorage;
protected Aggregator aggregator;
protected StatViewer viewer;
public ScheduleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
Map<String, RequestStat> stats = doStat(startTimeInMillis, endTimeInMillis);
viewer.output(stats, startTimeInMillis, endTimeInMillis);
}
private Map<String, RequestStat> doStat(long startTimeInMillis, long endTimeInMillis) {
Map<String, List<RequestStat>> segmentStats = new HashMap<>();
long segmentStartTimeMillis = ≠startTimeInMillis;
while (segmentStartTimeMillis < endTimeInMillis) {
long segmentEndTimeMillis = segmentStartTimeMillis + MAX_STAT_DURATION_IN_MILLIS;
if (segmentEndTimeMillis > endTimeInMillis) {
segmentEndTimeMillis = endTimeInMillis;
}
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(segmentStartTimeMillis, segmentEndTimeMillis);
if (requestInfos == null || requestInfos.isEmpty()) {
continue;
}
Map<String, RequestStat> segmentStat = aggregator.aggregate(
requestInfos, segmentEndTimeMillis - segmentStartTimeMillis);
addStat(segmentStats, segmentStat);
segmentStartTimeMillis += MAX_STAT_DURATION_IN_MILLIS;
}
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String, RequestStat> aggregatedStats = aggregateStats(segmentStats, durationInMillis);
return aggregatedStats;
}
private void addStat(Map<String, List<RequestStat>> segmentStats,
Map<String, RequestStat> segmentStat) {
for (Map.Entry<String, RequestStat> entry : segmentStat.entrySet()) {
String apiName = entry.getKey();
RequestStat stat = entry.getValue();
List<RequestStat> statList = segmentStats.putIfAbsent(apiName, new ArrayList<>());
statList.add(stat);
}
}
private Map<String, RequestStat> aggregateStats(Map<String, List<RequestStat>> segmentStats,
long durationInMillis) {
Map<String, RequestStat> aggregatedStats = new HashMap<>();
for (Map.Entry<String, List<RequestStat>> entry : segmentStats.entrySet()) {
String apiName = entry.getKey();
List<RequestStat> apiStats = entry.getValue();
double maxRespTime = Double.MIN_VALUE;
double minRespTime = Double.MAX_VALUE;
long count = 0;
double sumRespTime = 0;
for (RequestStat stat : apiStats) {
if (stat.getMaxResponseTime() > maxRespTime) maxRespTime = stat.getMaxResponseTime();
if (stat.getMinResponseTime() < minRespTime) minRespTime = stat.getMinResponseTime();
count += stat.getCount();
sumRespTime += (stat.getCount() * stat.getAvgResponseTime());
}
RequestStat aggregatedStat = new RequestStat();
aggregatedStat.setMaxResponseTime(maxRespTime);
aggregatedStat.setMinResponseTime(minRespTime);
aggregatedStat.setAvgResponseTime(sumRespTime / count);
aggregatedStat.setCount(count);
aggregatedStat.setTps(count / durationInMillis * 1000);
aggregatedStats.put(apiName, aggregatedStat);
}
return aggregatedStats;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# (3)扩展性
前面提到, 框架的扩展性有别于代码的扩展性, 是从使用者的角度来讲的, 特指使用者可以在不修改框架源码, 甚至不拿到框架源码的情况下, 为框架扩展新的功能.
在刚刚讲到框架的易用性的时候, 给出了框架如何使用的代码示例. 从示例中可以发现, 框架在兼顾易用性的同时, 也可以灵活地替换各种类对象, 比如 MetricsStorage, StatViewer. 举个例子来说, 如果要让框架基于 HBase 来存储原始数据而非 Redis, 那只需要设计一个实现 MetricsStorage 接口的 HBaseMetricsStorage 类, 传递给 MetricsCollector 和 ConsoleReporter, EmailReporter 类即可.
# (4)容错性
容错性这一点也非常重要. 对于这个框架来说, 不能因为框架本身的异常导致接口请求出错. 所以, 对框架可能存在的各种异常情况, 都要考虑全面.
在现在的框架设计与实现中, 采集和存储是异步执行, 即便 Redis 挂掉或者写入超时, 也不会影响到接口的正常响应. 除此之外, Redis 异常, 可能会影响到数据统计显示(也就是 ConsoleReporter, EmailReporter 负责的工作), 但并不会影响到接口的正常响应.
# (5)通用性
为了提高框架的复用性, 能够灵活应用到各种场景中, 框架在设计的时候, 要尽可能通用. 要多去思考一下, 除了接口统计这样一个需求, 这个框架还可以适用到其他哪些场景中. 比如是否还可以处理其他事件的统计信息, 比如 SQL 请求时间的统计, 业务统计(比如支付成功率)等.
重点回顾
针对性能计数器这个框架的开发, 要想一下子实现罗列的所有功能, 对任何人来说都是比较有挑战的. 而经过这几个版本的迭代之后, 慢慢就完成了几乎所有的需求, 包括功能性和非功能性的需求.
最开始实现了一个最小原型, 虽然非常简陋, 所有的代码都塞在一个类中, 但它帮我们梳理清楚了需求. 后面实现了框架的第 1 个版本, 这个版本只包含最基本的功能, 并且初步利用面向对象的设计方法, 把不同功能的代码划分到了不同的类中. 接着实现了框架的第 2 个版本, 这个版本对第 1 个版本的代码结构进行了比较大的调整, 让整体代码结构更加合理, 清晰, 有逻辑性. 最后实现了框架的第 3 个版本, 对第 2 个版本遗留的细节问题进行了重构, 并且重点解决了框架的易用性和性能问题.
从上面的迭代过程可以发现, 大部分情况下, 都是针对问题解决问题, 每个版本都聚焦一小部分问题, 所以整个过程也没有感觉到有太大难度. 尽管迭代了 3 个版本, 但目前的设计和实现还有很多值得进一步优化和完善的地方.
最后, 我希望你不仅仅关注这个框架本身的设计和实现, 更重要的是学会这个逐步优化的方法, 以及其中涉及的一些编程技巧, 设计思路, 能够举一反三地用在其他项目中.
# 设计模式
# 41-单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?
从今天开始正式进入到设计模式的学习. 经典的设计模式有 23 种. 其中, 常用的并不是很多, 可能都不到一半.
网上有很多讲解单例模式的文章, 但大部分都侧重讲解, 如何来实现一个线程安全的单例. 今天也会讲到各种单例的实现方法, 但是, 这并不是专栏学习的重点, 重点是希望带搞清楚下面这样几个问题(第一个问题会在今天讲解, 后面三个问题放到下一节中讲解).
- 为什么要使用单例?
- 单例存在哪些问题?
- 单例与静态类的区别?
- 有何替代的解决方案?
# 1.为什么要使用单例?
单例设计模式(Singleton Design Pattern)理解起来非常简单. 一个类只允许创建一个对象(或者实例), 那这个类就是一个单例类, 这种设计模式就叫作单例设计模式, 简称单例模式.
为什么需要单例这种设计模式? 它能解决哪些问题? 接下来通过两个实例来讲解.
# (1)实战案例一:处理资源访问冲突
先来看第一个例子. 在这个例子中, 自定义实现了一个往文件中打印日志的 Logger 类. 具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); // true表示追加写入
}
public void log(String message) {
writer.write(mesasge);
}
}
// Logger类的应用示例:
public class UserController {
private Logger logger = new Logger();
public void login(String username, String password) {
// ...省略业务逻辑代码...
logger.log(username + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// ...省略业务逻辑代码...
logger.log("Created an order: " + order.toString());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
看完代码之后, 先别着急看下面的讲解, 可以先思考一下, 这段代码存在什么问题.
在上面的代码中, 所有的日志都写入到同一个文件 /Users/wangzheng/log.txt 中. 在 UserController 和 OrderController 中, 分别创建两个 Logger 对象. 在 Web 容器的 Servlet 多线程环境下, 如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数, 并且同时写日志到 log.txt 文件中, 那就有可能存在日志信息互相覆盖的情况.
为什么会出现互相覆盖呢? 可以这么类比着理解. 在多线程环境下, 如果两个线程同时给同一个共享变量加 1, 因为共享变量是竞争资源, 所以共享变量最后的结果有可能并不是加了 2, 而是只加了 1. 同理, 这里的 log.txt 文件也是竞争资源, 两个线程同时往里面写数据, 就有可能存在互相覆盖的情况.

那如何来解决这个问题呢? 最先想到的就是通过加锁的方式: 给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字), 同一时刻只允许一个线程调用执行 log() 函数. 具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
synchronized(this) {
writer.write(mesasge);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
不过, 你仔细想想, 这真的能解决多线程写入日志时互相覆盖的问题吗? 答案是否定的. 这是因为, 这种锁是一个对象级别的锁, 一个对象在不同的线程下同时调用 log() 函数, 会被强制要求顺序执行. 但是, 不同的对象之间并不共享同一把锁. 在不同的线程下, 通过不同的对象调用执行 log() 函数, 锁并不会起作用, 仍然有可能存在写入日志互相覆盖的问题.

稍微补充一下, 在刚刚的讲解和给出的代码中, 我故意 "隐瞒" 了一个事实: 给 log() 函数加不加对象级别的锁, 其实都没有关系. 因为 FileWriter 本身就是线程安全的, 它的内部实现中本身就加了对象级别的锁, 因此, 在在外层调用 write() 函数的时候, 再加对象级别的锁实际上是多此一举. 因为不同的 Logger 对象不共享 FileWriter 对象, 所以, FileWriter 对象级别的锁也解决不了数据写入互相覆盖的问题.
那该怎么解决这个问题呢? 实际上, 要想解决这个问题也不难, 只需要把对象级别的锁, 换成类级别的锁就可以了. 让所有的对象都共享同一把锁. 这样就避免了不同对象之间同时调用 log() 函数, 而导致的日志覆盖问题. 具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); // true表示追加写入
}
public void log(String message) {
synchronized(Logger.class) { // 类级别的锁
writer.write(mesasge);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
除了使用类级别锁之外, 实际上, 解决资源竞争问题的办法还有很多, 分布式锁是最常听到的一种解决方案. 不过, 实现一个安全可靠, 无 bug, 高性能的分布式锁, 并不是件容易的事情. 除此之外, 并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题: 多个线程同时往并发队列里写日志, 一个单独的线程负责将并发队列中的数据, 写入到日志文件. 这种方式实现起来也稍微有点复杂.
相对于这两种解决方案, 单例模式的解决思路就简单一些了. 单例模式相对于之前类级别锁的好处是, 不用创建那么多 Logger 对象, 一方面节省内存空间, 另一方面节省系统文件句柄(对于操作系统来说, 文件句柄也是一种资源, 不能随便浪费) .
将 Logger 设计成一个单例类, 程序中只允许创建一个 Logger 对象, 所有的线程共享使用的这一个 Logger 对象, 共享一个 FileWriter 对象, 而 FileWriter 本身是对象级别线程安全的, 也就避免了多线程情况下写日志会互相覆盖的问题.
按照这个设计思路实现 Logger 单例类. 具体代码如下所示:
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
private Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); // true表示追加写入
}
public static Logger getInstance() {
return instance;
}
public void log(String message) {
writer.write(mesasge);
}
}
// Logger类的应用示例:
public class UserController {
public void login(String username, String password) {
// ...省略业务逻辑代码...
Logger.getInstance().log(username + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// ...省略业务逻辑代码...
Logger.getInstance().log("Created a order: " + order.toString());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# (2)实战案例二:表示全局唯一类
从业务概念上, 如果有些数据在系统中只应保存一份, 那就比较适合设计为单例类. 比如, 配置信息类. 在系统中只有一个配置文件, 当配置文件被加载到内存之后, 以对象的形式存在, 也理所应当只有一份.
再比如, 唯一递增 ID 号码生成器(前面讲的是唯一 ID 生成器, 这里是唯一递增 ID 生成器), 如果程序中有两个对象, 那就会存在生成重复 ID 的情况, 所以应该将 ID 生成器类设计为单例.
import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
// AtomicLong是一个Java并发库中提供的一个原子变量类型,
// 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
// 比如下面会用到的incrementAndGet().
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {
}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使用举例
long id = IdGenerator.getInstance().getId();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
实际上, 今天讲到的两个代码实例(Logger, IdGenerator), 设计的都并不优雅, 还存在一些问题. 至于有什么问题以及如何改造, 暂时卖个关子, 下一节会详细讲解.
# 2.如何实现一个单例?
尽管介绍如何实现一个单例模式的文章已经有很多了, 但为了保证内容的完整性, 这里还是简单介绍一下几种经典实现方式. 概括起来, 要实现一个单例, 需要关注的点无外乎下面几个:
- 构造函数需要是 private 访问权限的, 这样才能避免外部通过 new 创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑 getInstance() 性能是否高(是否加锁).
# (1)饿汉式
饿汉式的实现方式比较简单. 在类加载的时候, instance 静态实例就已经创建并初始化好了, 所以, instance 实例的创建过程是线程安全的. 不过, 这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候, 再创建实例), 从名字中也可以看出这一点. 具体的代码实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
有人觉得这种实现方式不好, 因为不支持延迟加载, 如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件), 提前初始化实例是一种浪费资源的行为. 最好的方法应该在用到的时候再去初始化. 不过, 我个人并不认同这样的观点.
如果初始化耗时长, 那最好不要等到真正要用它的时候, 才去执行这个耗时长的初始化过程, 这会影响到系统的性能(比如, 在响应客户端接口请求的时候, 做这个初始化操作, 会导致此请求的响应时间变长, 甚至超时). 采用饿汉式实现方式, 将耗时的初始化操作, 提前到程序启动的时候完成, 这样就能避免在程序运行的时候, 再去初始化导致的性能问题.
如果实例占用资源多, 按照 fail-fast 的设计原则(有问题及早暴露), 那也希望在程序启动时就将这个实例初始化好. 如果资源不够, 就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM), 可以立即去修复. 这样也能避免在程序运行一段时间后, 突然因为初始化这个实例占用资源过多, 导致系统崩溃, 影响系统的可用性.
# (2)懒汉式
有饿汉式, 对应地, 就有懒汉式. 懒汉式相对于饿汉式的优势是支持延迟加载. 具体的代码实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
不过懒汉式的缺点也很明显, 给 getInstance() 这个方法加了一把大锁(synchronzed), 导致这个函数的并发度很低. 量化一下的话, 并发度是 1, 也就相当于串行操作了. 而这个函数是在单例使用期间, 一直会被调用. 如果这个单例类偶尔会被用到, 那这种实现方式还可以接受. 但如果频繁地用到, 那频繁加锁, 释放锁及并发度低等问题, 会导致性能瓶颈, 这种实现方式就不可取了.
# (3)双重检测
饿汉式不支持延迟加载, 懒汉式有性能问题, 不支持高并发. 那再来看一种既支持延迟加载, 又支持高并发的单例实现方式, 也就是双重检测实现方式.
在这种实现方式中, 只要 instance 被创建之后, 即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了. 所以这种实现方式解决了懒汉式并发度低的问题. 具体的代码实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static volatile IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
if (instance <mark> null) {
synchronized(IdGenerator.class) { // 此处为类级别的锁
if (instance </mark> null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
网上有人说, 这种实现方式有些问题. 因为指令重排序, 可能会导致 IdGenerator 对象被 new 出来, 并且赋值给 instance 之后, 还没来得及初始化(执行构造函数中的代码逻辑), 就被另一个线程使用了.
要解决这个问题, 需要给 instance 成员变量加上 volatile 关键字, 禁止指令重排序才行. 实际上, 只有很低版本的 Java 才会有这个问题. 现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单, 只要把对象 new 操作和初始化操作设计为原子操作, 就自然能禁止重排序).
# (4)静态内部类
再来看一种比双重检测更加简单的实现方法, 那就是利用 Java 的静态内部类. 它有点类似饿汉式, 但又能做到了延迟加载. 具体是怎么做到的呢? 先来看它的代码实现.
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {}
private static class SingletonHolder {
private static final IdGenerator instance = new IdGenerator();
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SingletonHolder 是一个静态内部类, 当外部类 IdGenerator 被加载的时候, 并不会创建 SingletonHolder 实例对象. 只有当调用 getInstance() 方法时, SingletonHolder 才会被加载, 这个时候才会创建 instance. insance 的唯一性, 创建过程的线程安全性, 都由 JVM 来保证. 所以, 这种实现方法既保证了线程安全, 又能做到延迟加载.
# (5)枚举
最后介绍一种最简单的实现方式, 基于枚举类型的单例实现. 这种实现方式通过 Java 枚举类型本身的特性, 保证了实例创建的线程安全性和实例的唯一性. 具体的代码如下所示:
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
# 重点回顾
1.单例的定义
单例设计模式(Singleton Design Pattern)理解起来非常简单. 一个类只允许创建一个对象(或者叫实例), 那这个类就是一个单例类, 这种设计模式就叫作单例设计模式, 简称单例模式.
2.单例的用处
从业务概念上, 有些数据在系统中只应该保存一份, 就比较适合设计为单例类. 比如, 系统的配置信息类. 除此之外, 还可以使用单例解决资源访问冲突的问题.
3.单例的实现
单例有下面几种经典的实现方式.
饿汉式
饿汉式的实现方式, 在类加载的期间, 就已经将 instance 静态实例初始化好了, 所以 instance 实例的创建是线程安全的. 不过, 这样的实现方式不支持延迟加载实例.
懒汉式
懒汉式相对于饿汉式的优势是支持延迟加载. 这种实现方式会导致频繁加锁, 释放锁, 以及并发度低等问题, 频繁的调用会产生性能瓶颈.
双重检测式
双重检测实现方式既支持延迟加载, 又支持高并发的单例实现方式. 只要 instance 被创建之后, 再调用 getInstance() 函数都不会进入到加锁逻辑中. 所以, 这种实现方式解决了懒汉式并发度低的问题.
静态内部类
利用 Java 的静态内部类来实现单例. 这种实现方式, 既支持延迟加载, 也支持高并发, 实现起来也比双重检测简单.
枚举
最简单的实现方式, 基于枚举类型的单例实现. 这种实现方式通过 Java 枚举类型本身的特性, 保证了实例创建的线程安全性和实例的唯一性.
# 42-单例模式(中):为什么不推荐使用单例模式?又有何替代方案?
尽管单例是一个很常用的设计模式, 在实际的开发中, 也确实经常用到它, 但是有些人认为单例是一种反模式(anti-pattern), 并不推荐使用. 所以今天就针对这个说法详细地讲讲这几个问题: 单例这种设计模式存在哪些问题? 为什么会被称为反模式? 如果不用单例, 该如何表示全局唯一类? 有何替代的解决方案?
# 1.单例存在哪些问题?
大部分情况下, 在项目中使用单例, 都是用它来表示一些全局唯一类, 比如配置信息类, 连接池类, ID 生成器类. 单例模式书写简洁, 使用方便, 在代码中, 不需要创建对象, 直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了. 但是, 这种使用方法有点类似硬编码(hard code), 会带来诸多问题. 接下来就具体看看到底有哪些问题.
# (1)单例对OOP特性的支持不友好
OOP 的四大特性是封装, 抽象, 继承, 多态. 单例这种设计模式对于其中的抽象, 继承, 多态都支持得不好. 为什么这么说呢? 还是通过 IdGenerator 这个例子来讲解.
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
IdGenerator 的使用方式违背了基于接口而非实现的设计原则, 也就违背了广义上理解的 OOP 的抽象特性. 如果未来某一天, 希望针对不同的业务采用不同的 ID 生成算法. 比如, 订单 ID 和用户 ID 采用不同的 ID 生成器来生成. 为了应对这个需求变化, 需要修改所有用到 IdGenerator 类的地方, 这样代码的改动就会比较大.
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码, 替换为下面一行代码
long id = OrderIdGenerator.getIntance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码, 替换为下面一行代码
long id = UserIdGenerator.getIntance().getId();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
除此之外, 单例对继承, 多态特性的支持也不友好. 这里之所以会用 "不友好" 这个词, 而非 "完全不支持", 是因为从理论上来讲, 单例类也可以被继承, 也可以实现多态, 只是实现起来会非常奇怪, 会导致代码的可读性变差. 不明白设计意图的人, 看到这样的设计, 会觉得莫名其妙. 所以, 一旦选择将某个类设计成到单例类, 也就意味着放弃了继承和多态这两个强有力的面向对象特性, 也就相当于损失了可以应对未来需求变化的扩展性.
# (2)单例会隐藏类之间的依赖关系
代码的可读性非常重要. 在阅读代码的时候, 自然希望一眼就能看出类与类之间的依赖关系, 搞清楚这个类依赖了哪些外部类.
通过构造函数, 参数传递等方式声明的类之间的依赖关系, 通过查看函数的定义, 就能很容易识别出来. 但是, 单例类不需要显示创建, 不需要依赖参数传递, 在函数中直接调用就可以了. 如果代码比较复杂, 这种调用关系就会非常隐蔽. 在阅读代码的时候, 就需要仔细查看每个函数的代码实现, 才能知道这个类到底依赖了哪些单例类.
# (3)单例对代码的扩展性不友好
单例类只能有一个对象实例. 如果未来某一天, 需要在代码中创建两个实例或多个实例, 那就要对代码有比较大的改动. 你可能会说, 会有这样的需求吗? 既然单例类大部分情况下都用来表示全局类, 怎么会需要两个或者多个实例呢?
实际上, 这样的需求并不少见. 拿数据库连接池来举例解释一下.
在系统设计初期, 我们觉得系统中只应该有一个数据库连接池, 这样能方便控制对数据库连接资源的消耗. 所以把数据库连接池类设计成了单例类. 但之后发现, 系统中有些 SQL 语句运行得非常慢. 这些 SQL 语句在执行的时候, 长时间占用数据库连接资源, 导致其他 SQL 请求无法响应. 为了解决这个问题, 希望将慢 SQL 与其他 SQL 隔离开来执行. 为了实现这样的目的, 可以在系统中创建两个数据库连接池, 慢 SQL 独享一个数据库连接池, 其他 SQL 独享另外一个数据库连接池, 这样就能避免慢 SQL 影响到其他 SQL 的执行.
如果将数据库连接池设计成单例类, 显然就无法适应这样的需求变更, 也就是说, 单例类在某些情况下会影响代码的扩展性, 灵活性. 所以, 数据库连接池, 线程池这类的资源池, 最好还是不要设计成单例类. 实际上, 一些开源的数据库连接池, 线程池也确实没有设计成单例类.
# (4)单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性. 如果单例类依赖比较重的外部资源, 比如 DB, 在写单元测试的时候, 希望能通过 mock 的方式将它替换掉. 而单例类这种硬编码式的使用方式, 导致无法实现 mock 替换.
除此之外, 如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量), 那它实际上相当于一种全局变量, 被所有的代码共享. 如果这个全局变量是一个可变全局变量, 也就是说, 它的成员变量是可以被修改的, 那在编写单元测试的时候, 还需要注意不同测试用例之间, 修改了单例类中的同一个成员变量的值, 从而导致测试结果互相影响的问题.
# (5)单例不支持有参数的构造函数
单例不支持有参数的构造函数, 比如创建一个连接池的单例对象, 没法通过参数来指定连接池的大小. 针对这个问题, 来看下都有哪些解决方案.
第一种解决思路是: 创建完实例之后, 再调用 init() 函数传递参数. 需要注意的是, 在使用这个单例类的时候, 要先调用 init() 方法, 然后才能调用 getInstance() 方法, 否则代码会抛出异常. 具体的代码实现如下所示:
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}
public static Singleton getInstance() {
if (instance == null) {
throw new RuntimeException("Run init() first.");
}
return instance;
}
public synchronized static Singleton init(int paramA, int paramB) {
if (instance != null){
throw new RuntimeException("Singleton has been created!");
}
instance = new Singleton(paramA, paramB);
return instance;
}
}
Singleton.init(10, 50); // 先init, 再使用
Singleton singleton = Singleton.getInstance();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
第二种解决思路是: 将参数放到 getIntance() 方法中. 具体的代码实现如下所示:
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}
public synchronized static Singleton getInstance(int paramA, int paramB) {
if (instance == null) {
instance = new Singleton(paramA, paramB);
}
return instance;
}
}
Singleton singleton = Singleton.getInstance(10, 50);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
其实上面的代码实现稍微有点问题. 如果如下两次执行 getInstance() 方法, 那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50. 也就是说, 第二次的参数(20, 30)没有起作用, 而构建的过程也没有给与提示, 这样就会误导用户. 这个问题如何解决呢? 留给你自己思考.
Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);
2
第三种解决思路是: 将参数放到另外一个全局变量中. 具体的代码实现如下. Config 是一个存储了 paramA 和 paramB 值的全局变量. 里面的值既可以像下面的代码那样通过静态常量来定义, 也可以从配置文件中加载得到. 实际上, 这种方式是最值得推荐的.
public class Config {
public static final int PARAM_A = 123;
public static fianl int PARAM_B = 245;
}
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton() {
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 2.有何替代解决方案?
刚刚提到了单例的很多问题, 你可能会说, 即便单例有这么多问题, 但我不用不行啊. 业务上有表示全局唯一类的需求, 如果不用单例, 怎么才能保证这个类的对象全局唯一呢?
为了保证全局唯一, 除了使用单例, 还可以用静态方法来实现. 这也是项目开发中经常用到的一种实现思路. 比如前面讲的 ID 唯一递增生成器的例子, 用静态方法实现一下, 就是下面这个样子:
// 静态方法实现方式
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
public static long getId() {
return id.incrementAndGet();
}
}
// 使用举例
long id = IdGenerator.getId();
2
3
4
5
6
7
8
9
10
11
不过, 静态方法这种实现思路, 并不能解决之前提到的问题. 实际上, 它比单例更加不灵活, 比如它无法支持延迟加载. 再来看看有没有其他办法. 实际上, 单例除了之前讲到的使用方法之外, 还有另外一个种使用方法. 具体的代码如下所示:
// 1. 老的使用方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}
// 2. 新的使用方式: 依赖注入
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部调用demofunction()的时候, 传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
基于新的使用方式, 将单例生成的对象, 作为参数传递给函数(也可以通过构造函数传递给类的成员变量), 可以解决单例隐藏类之间依赖关系的问题. 不过, 对于单例存在的其他问题, 比如对 OOP 特性, 扩展性, 可测性不友好等问题, 还是无法解决.
所以, 如果要完全解决这些问题, 可能要从根上, 寻找其他方式来实现全局唯一类. 实际上, 类对象的全局唯一性可以通过多种不同的方式来保证. 既可以通过单例模式来强制保证, 也可以通过工厂模式, IOC 容器(比如 Spring IOC 容器)来保证, 还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象). 这就类似 Java 中内存对象的释放由 JVM 来负责, 而 C++ 中由程序员自己负责, 道理是一样的.
对于替代方案工厂模式, IOC 容器的详细讲解, 会在后面的章节中讲解.
重点回顾
**1.单例存在哪些问题? **
- 单例对 OOP 特性的支持不友好
- 单例会隐藏类之间的依赖关系
- 单例对代码的扩展性不友好
- 单例对代码的可测试性不友好
- 单例不支持有参数的构造函数
**2.单例有什么替代解决方案? **
为了保证全局唯一, 除了使用单例, 还可以用静态方法来实现. 不过, 静态方法这种实现思路, 并不能解决之前提到的问题. 如果要完全解决这些问题, 可能要从根上, 寻找其他方式来实现全局唯一类了. 比如, 通过工厂模式, IOC 容器(比如 Spring IOC 容器)来保证, 由过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象).
有人把单例当作反模式, 主张杜绝在项目中使用. 个人觉得这有点极端. 模式没有对错, 关键看你怎么用. 如果单例类并没有后续扩展的需求, 并且不依赖外部系统, 那设计成单例类就没有太大问题. 对于一些全局的类, 在其他地方 new 的话, 还要在类之间传来传去, 不如直接做成单例类, 使用起来简洁方便.
# 43-单例模式(下):如何设计实现一个集群环境下的分布式单例模式?
今天再进一步扩展延伸一下, 一块讨论一下下面这几个问题:
- 如何理解单例模式中的唯一性?
- 如何实现线程唯一的单例?
- 如何实现集群环境下的单例?
- 如何实现一个多例模式?
# 1.如何理解单例模式中的唯一性?
首先, 重新看一下单例的定义: "一个类只允许创建唯一一个对象(或者实例), 那这个类就是一个单例类, 这种设计模式就叫作单例设计模式, 简称单例模式."
定义中提到, "一个类只允许创建唯一一个对象". 那对象的唯一性的作用范围是什么呢? 是指线程内只允许创建一个对象, 还是指进程内只允许创建一个对象? 答案是后者, 也就是说, 单例模式创建的对象是进程唯一的. 这里有点不好理解, 详细地解释一下.
代码通过编译, 链接, 组织在一起, 就构成了一个操作系统可以执行的文件, 也就是平时所说的"可执行文件"(比如 Windows 下的 exe 文件). 可执行文件实际上就是代码被翻译成操作系统可理解的一组指令
当使用命令行或者双击运行这个可执行文件的时候, 操作系统会启动一个进程, 将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区, 用来存储代码和数据). 接着, 进程就一条一条地执行可执行文件中包含的代码. 比如, 当进程读到代码中的 User user = new User(); 这条语句的时候, 它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象.
进程之间是不共享地址空间的, 如果在一个进程中创建另外一个进程(比如, 代码中有一个 fork() 语句, 进程执行到这条语句的时候会创建一个新的进程), 操作系统会给新进程分配新的地址空间, 并且将老进程地址空间的所有内容, 重新拷贝一份到新进程的地址空间中, 这些内容包括代码, 数据(比如 user 临时变量, User 对象).
所以, 单例类在老进程中存在且只能存在一个对象, 在新进程中也会存在且只能存在一个对象. 而且, 这两个对象并不是同一个对象, 这也就说, 单例类中对象的唯一性的作用范围是进程内的, 在进程间是不唯一的.
# 2.如何实现线程唯一的单例?
刚刚讲了单例类对象是进程唯一的, 一个进程只能有一个单例对象. 那如何实现一个线程唯一的单例呢?
先来看一下, 什么是线程唯一的单例, 以及 "线程唯一" 和 "进程唯一" 的区别.
"进程唯一" 指的是进程内唯一, 进程间不唯一. 类比一下, "线程唯一" 指的是线程内唯一, 线程间可以不唯一. 实际上, "进程唯一" 还代表了线程内, 线程间都唯一, 这也是 "进程唯一" 和 "线程唯一" 的区别之处. 这段话听起来有点像绕口令, 举个例子解释一下.
假设 IdGenerator 是一个线程唯一的单例类. 在线程 A 内, 可以创建一个单例对象 a. 因为线程内唯一, 在线程 A 内就不能再创建新的 IdGenerator 对象了, 而线程间可以不唯一, 所以, 在另外一个线程 B 内, 还可以重新创建一个新的单例对象 b.
尽管概念理解起来比较复杂, 但线程唯一单例的代码实现很简单, 如下所示. 在代码中, 通过一个 HashMap 来存储对象, 其中 key 是线程 ID, value 是对象. 这样就可以做到, 不同的线程对应不同的对象, 同一个线程只能对应一个对象. 实际上, Java 语言本身提供了 ThreadLocal 工具类, 可以更加轻松地实现线程唯一单例. 不过, ThreadLocal 底层实现原理也是基于下面代码中所示的 HashMap.
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final ConcurrentHashMap<Long, IdGenerator> instances
= new ConcurrentHashMap<>();
private IdGenerator() {}
public static IdGenerator getInstance() {
Long currentThreadId = Thread.currentThread().getId();
instances.putIfAbsent(currentThreadId, new IdGenerator());
return instances.get(currentThreadId);
}
public long getId() {
return id.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 3.如何实现集群环境下的单例?
刚刚讲了 "进程唯一" 的单例和 "线程唯一" 的单例, 再来看下, "集群唯一" 的单例.
首先还是先来解释一下, 什么是 "集群唯一" 的单例. 集群相当于多个进程构成的一个集合, "集群唯一" 就相当于是进程内唯一, 进程间也唯一. 也就是说, 不同的进程间共享同一个对象, 不能创建同一个类的多个对象.
经典的单例模式是进程内唯一的, 那如何实现一个进程间也唯一的单例呢? 如果严格按照不同的进程间共享同一个对象来实现, 那集群唯一的单例实现起来就有点难度了.
具体来说, 需要把这个单例对象序列化并存储到外部共享存储区(比如文件). 进程在使用这个单例对象的时候, 需要先从外部共享存储区中将它读取到内存, 并反序列化成对象, 然后再使用, 使用完成之后还需要再存储回外部共享存储区.
为了保证任何时刻, 在进程间都只有一份对象存在, 一个进程在获取到对象之后, 需要对对象加锁, 避免其他进程再将其获取. 在进程使用完这个对象之后, 还需要显式地将对象从内存中删除, 并且释放对对象的加锁.
按照这个思路, 我用伪代码实现了一下这个过程, 具体如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略, 比如文件地址*/);
private static DistributedLock lock = new DistributedLock();
private IdGenerator() {
}
public synchronized static IdGenerator getInstance() {
if (instance == null) {
lock.lock();
instance = storage.load(IdGenerator.class);
}
return instance;
}
public synchroinzed
void freeInstance() {
storage.save(this, IdGeneator.class);
instance = null; //释放对象
lock.unlock();
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 4.如何实现一个多例模式?
跟单例模式概念相对应的还有一个多例模式. 那如何实现一个多例模式呢?
"单例" 指的是, 一个类只能创建一个对象. 对应地, "多例" 指的就是, 一个类可以创建多个对象, 但是个数是有限制的, 比如只能创建 3 个对象. 如果用代码来简单示例一下的话, 就是下面这个样子:
public class BackendServer {
private long serverNo;
private String serverAddress;
private static final int SERVER_COUNT = 3;
private static final Map<Long, BackendServer> serverInstances = new HashMap<>();
static {
serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
}
private BackendServer(long serverNo, String serverAddress) {
this.serverNo = serverNo;
this.serverAddress = serverAddress;
}
public BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}
public BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT)+1;
return serverInstances.get(no);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
实际上, 对于多例模式, 还有一种理解方式: 同一类型的只能创建一个对象, 不同类型的可以创建多个对象. 这里的 "类型" 如何理解呢?
还是通过一个例子来解释一下, 具体代码如下所示. 在代码中, logger name 就是刚刚说的 "类型", 同一个 logger name 获取到的对象实例是相同的, 不同的 logger name 获取到的对象实例是不同的.
public class Logger {
private static final ConcurrentHashMap<String, Logger> instances
= new ConcurrentHashMap<>();
private Logger() {}
public static Logger getInstance(String loggerName) {
instances.putIfAbsent(loggerName, new Logger());
return instances.get(loggerName);
}
public void log() {
//...
}
}
//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这种多例模式的理解方式有点类似工厂模式. 它跟工厂模式的不同之处是, 多例模式创建的对象都是同一个类的对象, 而工厂模式创建的是不同子类的对象, 关于这一点, 下一节就会讲到. 实际上, 它还有点类似享元模式, 两者的区别等到讲到享元模式的时候再来分析. 除此之外, 实际上, 枚举类型也相当于多例模式, 一个类型只能对应一个对象, 一个类可以创建多个对象.
重点回顾
**1.如何理解单例模式的唯一性? **
单例类中对象的唯一性的作用范围是 "进程唯一" 的. "进程唯一" 指的是进程内唯一, 进程间不唯一; "线程唯一" 指的是线程内唯一, 线程间可以不唯一. 实际上, "进程唯一" 就意味着线程内, 线程间都唯一, 这也是 "进程唯一" 和 "线程唯一" 的区别之处. "集群唯一" 指的是进程内唯一, 进程间也唯一.
**2.如何实现线程唯一的单例? **
通过一个 HashMap 来存储对象, 其中 key 是线程 ID, value 是对象. 这样就可以做到不同的线程对应不同的对象, 同一个线程只能对应一个对象. 实际上, Java 语言本身提供了 ThreadLocal 并发工具类, 可以更加轻松地实现线程唯一单例.
**3.如何实现集群环境下的单例? **
需要把这个单例对象序列化并存储到外部共享存储区(比如文件). 进程在使用这个单例对象的时候, 需要先从外部共享存储区中将它读取到内存, 并反序列化成对象, 然后再使用, 使用完成之后还需要再存储回外部共享存储区. 为了保证任何时刻在进程间都只有一份对象存在, 一个进程在获取到对象之后, 需要对对象加锁, 避免其他进程再将其获取. 在进程使用完这个对象之后, 需要显式地将对象从内存中删除, 并且释放对对象的加锁.
**4.如何实现一个多例模式? **
"单例" 指的是一个类只能创建一个对象. 对应地, "多例" 指的就是一个类可以创建多个对象, 但是个数是有限制的, 比如只能创建 3 个对象. 多例的实现也比较简单, 通过一个 Map 来存储对象类型和对象之间的对应关系, 来控制对象的个数.
# 44-工厂模式(上):为什么说没事不要随便用工厂模式创建对象?
今天来讲一个比较常用的创建型模式: 工厂模式(Factory Design Pattern).
一般情况下, 工厂模式分为三种更加细分的类型: 简单工厂, 工厂方法和抽象工厂. 不过, 在 GoF 的《设计模式》一书中, 它将简单工厂模式看作是工厂方法模式的一种特例, 所以工厂模式只被分成了工厂方法和抽象工厂两类. 实际上, 前面一种分类方法更加常见, 所以本节沿用第一种分类方法.
在这三种细分的工厂模式中, 简单工厂, 工厂方法原理比较简单, 在实际的项目中也比较常用. 而抽象工厂的原理稍微复杂点, 在实际的项目中相对也不常用. 所以今天讲解的重点是前两种工厂模式. 对于抽象工厂, 稍微了解一下即可.
除此之外, 重点也不是原理和实现, 因为这些都很简单, 重点还是搞清楚应用场景: 什么时候该用工厂模式? 相对于直接 new 来创建对象, 用工厂模式来创建究竟有什么好处呢?
# 1.简单工厂(Simple Factory)
首先来看, 什么是简单工厂模式. 通过一个例子来解释一下.
在下面这段代码中, 根据配置文件的后缀(json, xml, yaml, properties), 选择不同的解析器(JsonRuleConfigParser, XmlRuleConfigParser...), 将存储在文件中的配置解析成内存对象 RuleConfig.
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new PropertiesRuleConfigParser();
} else {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}
String configText = "";
// 从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}
private String getFileExtension(String filePath) {
// ...解析文件名获取扩展名, 比如rule.json, 返回json
return "json";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在 "规范和重构" 那一部分讲到, 为了让代码逻辑更加清晰, 可读性更好, 要善于将功能独立的代码块封装成函数. 按照这个设计思路, 可以将代码中涉及 parser 创建的部分逻辑剥离出来, 抽象成 createParser() 函数. 重构之后的代码如下所示:
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = createParser(ruleConfigFileExtension);
if (parser == null) {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}
String configText = "";
// 从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}
private String getFileExtension(String filePath) {
// ...解析文件名获取扩展名, 比如rule.json, 返回json
return "json";
}
private IRuleConfigParser createParser(String configFormat) {
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(configFormat)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(configFormat)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(configFormat)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(configFormat)) {
parser = new PropertiesRuleConfigParser();
}
return parser;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
为了让类的职责更加单一, 代码更加清晰, 还可以进一步将 createParser() 函数剥离到一个独立的类中, 让这个类只负责对象的创建. 而这个类就是现在要讲的简单工厂模式类. 具体的代码如下所示:
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension);
if (parser == null) {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}
String configText = "";
// 从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}
private String getFileExtension(String filePath) {
// ...解析文件名获取扩展名, 比如rule.json, 返回json
return "json";
}
}
public class RuleConfigParserFactory {
public static IRuleConfigParser createParser(String configFormat) {
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(configFormat)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(configFormat)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(configFormat)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(configFormat)) {
parser = new PropertiesRuleConfigParser();
}
return parser;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
大部分工厂类都是以 "Factory" 这个单词结尾的, 但也不是必须的, 比如 Java 中的 DateFormat, Calender. 除此之外, 工厂类中创建对象的方法一般都是 create 开头, 比如代码中的 createParser(), 但有的也命名为 getInstance(), createInstance(), newInstance(), 有的甚至命名为 valueOf() (比如 Java String 类的 valueOf() 函数)等等, 这个根据具体的场景和习惯来命名就好.
在上面的代码实现中, 每次调用 RuleConfigParserFactory 的 createParser() 的时候, 都要创建一个新的 parser. 实际上, 如果 parser 可以复用, 为了节省内存和对象创建的时间, 可以将 parser 事先创建好缓存起来. 当调用 createParser() 函数的时候, 从缓存中取出 parser 对象直接使用.
这有点类似单例模式和简单工厂模式的结合, 具体的代码实现如下所示. 在接下来的讲解中, 把上一种实现方法叫作简单工厂模式的第一种实现方法, 把下面这种实现方法叫作简单工厂模式的第二种实现方法.
public class RuleConfigParserFactory {
private static final Map<String, RuleConfigParser> cachedParsers = new HashMap<>();
static {
cachedParsers.put("json", new JsonRuleConfigParser());
cachedParsers.put("xml", new XmlRuleConfigParser());
cachedParsers.put("yaml", new YamlRuleConfigParser());
cachedParsers.put("properties", new PropertiesRuleConfigParser());
}
public static IRuleConfigParser createParser(String configFormat) {
if (configFormat == null || configFormat.isEmpty()) {
return null;// 返回null还是IllegalArgumentException全凭你自己说了算
}
IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase());
return parser;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
对于上面两种简单工厂模式的实现方法, 如果要添加新的 parser, 那势必要改动到 RuleConfigParserFactory 的代码, 那这是不是违反开闭原则呢? 实际上, 如果不是需要频繁地添加新的 parser, 只是偶尔修改一下 RuleConfigParserFactory 代码, 稍微不符合开闭原则, 也是完全可以接受的.
除此之外, 在 RuleConfigParserFactory 的第一种代码实现中, 有一组 if 分支判断逻辑, 是不是应该用多态或其他设计模式来替代呢? 实际上, 如果 if 分支并不是很多, 代码中有 if 分支也是完全可以接受的. 应用多态或设计模式来替代 if 分支判断逻辑, 也并不是没有任何缺点的, 它虽然提高了代码的扩展性, 更加符合开闭原则, 但也增加了类的个数, 牺牲了代码的可读性.
总结一下, 尽管简单工厂模式的代码实现中, 有多处 if 分支判断逻辑, 违背开闭原则, 但权衡扩展性和可读性, 这样的代码实现在大多数情况下(比如, 不需要频繁地添加 parser, 也没有太多的 parser)是没有问题的.
# 2.工厂方法(Factory Method)
如果非得要将 if 分支逻辑去掉, 那该怎么办呢? 比较经典处理方法就是利用多态. 按照多态的实现思路, 对上面的代码进行重构. 重构之后的代码如下所示:
public interface IRuleConfigParserFactory {
IRuleConfigParser createParser();
}
public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new JsonRuleConfigParser();
}
}
public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new XmlRuleConfigParser();
}
}
public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new YamlRuleConfigParser();
}
}
public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new PropertiesRuleConfigParser();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
实际上, 这就是工厂方法模式的典型代码实现. 这样当新增一种 parser 的时候, 只需要新增一个实现了 IRuleConfigParserFactory 接口的 Factory 类即可. 所以, **工厂方法模式比起简单工厂模式更加符合开闭原则. **
从上面的工厂方法的实现来看, 一切都很完美, 但是实际上存在挺大的问题. 问题存在于这些工厂类的使用上. 接下来看一下, 如何用这些工厂类来实现 RuleConfigSource 的 load() 函数. 具体的代码如下所示:
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParserFactory parserFactory = null;
if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new JsonRuleConfigParserFactory();
} else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new XmlRuleConfigParserFactory();
} else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new YamlRuleConfigParserFactory();
} else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new PropertiesRuleConfigParserFactory();
} else {
throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
}
IRuleConfigParser parser = parserFactory.createParser();
String configText = "";
// 从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}
private String getFileExtension(String filePath) {
// ...解析文件名获取扩展名, 比如rule.json, 返回json
return "json";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
从上面的代码实现来看, 工厂类对象的创建逻辑又耦合进了 load() 函数中, 跟最初的代码版本非常相似, 引入工厂方法非但没有解决问题, 反倒让设计变得更加复杂了. 那怎么来解决这个问题呢?
可以为工厂类再创建一个简单工厂, 也就是工厂的工厂, 用来创建工厂类对象 **. ** 这段话听起来有点绕, 我把代码实现出来了, 你一看就能明白了. 其中, RuleConfigParserFactoryMap 类是创建工厂对象的工厂类, getParserFactory() 返回的是缓存好的单例工厂对象.
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension);
if (parserFactory == null) {
throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
}
IRuleConfigParser parser = parserFactory.createParser();
String configText = "";
// 从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}
private String getFileExtension(String filePath) {
// ...解析文件名获取扩展名, 比如rule.json, 返回json
return "json";
}
}
// 因为工厂类只包含方法, 不包含成员变量, 完全可以复用,
// 不需要每次都创建新的工厂类对象, 所以简单工厂模式的第二种实现思路更加合适.
// 工厂的工厂
public class RuleConfigParserFactoryMap {
private static final Map<String, IRuleConfigParserFactory> cachedFactories = new HashMap<>();
static {
cachedFactories.put("json", new JsonRuleConfigParserFactory());
cachedFactories.put("xml", new XmlRuleConfigParserFactory());
cachedFactories.put("yaml", new YamlRuleConfigParserFactory());
cachedFactories.put("properties", new PropertiesRuleConfigParserFactory());
}
public static IRuleConfigParserFactory getParserFactory(String type) {
if (type == null || type.isEmpty()) {
return null;
}
IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase());
return parserFactory;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
当需要添加新的规则配置解析器的时候, 只需要创建新的 parser 类和 parser factory 类, 并且在 RuleConfigParserFactoryMap 类中, 将新的 parser factory 对象添加到 cachedFactories 中即可. 代码的改动非常少, 基本上符合开闭原则.
实际上, 对于规则配置文件解析这个应用场景来说, 工厂模式需要额外创建诸多 Factory 类, 也会增加代码的复杂性, 而且每个 Factory 类只是做简单的 new 操作, 功能非常单薄(只有一行代码), 也没必要设计成独立的类, 所以在这个应用场景下, 简单工厂模式简单好用, 比工方法厂模式更加合适.
**那什么时候该用工厂方法模式, 而非简单工厂模式呢? **
前面提到, 之所以将某个代码块剥离出来, 独立为函数或者类, 原因是这个代码块的逻辑过于复杂, 剥离之后能让代码更加清晰, 更加可读, 可维护. 但是, 如果代码块本身并不复杂, 就几行代码而已, 完全没必要将它拆分成单独的函数或者类.
基于这个设计思想, 当对象的创建逻辑比较复杂, 不只是简单的 new 一下就可以, 而是要组合其他类对象, 做各种初始化操作的时候, 推荐使用工厂方法模式, 将复杂的创建逻辑拆分到多个工厂类中, 让每个工厂类都不至于过于复杂. 而使用简单工厂模式, 将所有的创建逻辑都放到一个工厂类中, 会导致这个工厂类变得很复杂.
除此之外, 在某些场景下, 如果对象不可复用, 那工厂类每次都要返回不同的对象. 如果使用简单工厂模式来实现, 就只能选择第一种包含 if 分支逻辑的实现方式. 如果还想避免烦人的 if-else 分支逻辑, 这个时候就推荐使用工厂方法模式.
# 3.抽象工厂(Abstract Factory)
讲完了简单工厂, 工厂方法, 再来看抽象工厂模式. 抽象工厂模式的应用场景比较特殊, 没有前两种常用, 所以不是本节学习的重点, 简单了解一下就可以了.
在简单工厂和工厂方法中, 类只有一种分类方式. 比如在规则配置解析那个例子中, 解析器类只会根据配置文件格式(Json, Xml, Yaml...)来分类. 但如果类有两种分类方式, 比如既可以按照配置文件格式来分类, 也可以按照解析的对象(Rule 规则配置还是 System 系统配置)来分类, 那就会对应下面这 8 个 parser 类.
针对规则配置的解析器: 基于接口IRuleConfigParser
JsonRuleConfigParser
XmlRuleConfigParser
YamlRuleConfigParser
PropertiesRuleConfigParser
针对系统配置的解析器: 基于接口ISystemConfigParser
JsonSystemConfigParser
XmlSystemConfigParser
YamlSystemConfigParser
PropertiesSystemConfigParser
2
3
4
5
6
7
8
9
10
11
针对这种特殊的场景, 如果还是继续用工厂方法来实现的话, 要针对每个 parser 都编写一个工厂类, 也就是要编写 8 个工厂类. 如果未来还需要增加针对业务配置的解析器(比如 IBizConfigParser), 那就要再对应地增加 4 个工厂类. 而过多的类也会让系统难维护. 这个问题该怎么解决呢?
抽象工厂就是针对这种非常特殊的场景而诞生的. 可以让一个工厂负责创建多个不同类型的对象(IRuleConfigParser, ISystemConfigParser 等), 而不是只创建一种 parser 对象. 这样就可以有效地减少工厂类的个数. 具体的代码实现如下所示:
public interface IConfigParserFactory {
IRuleConfigParser createRuleParser();
ISystemConfigParser createSystemParser();
// 此处可以扩展新的parser类型, 比如IBizConfigParser
}
public class JsonConfigParserFactory implements IConfigParserFactory {
@Override
public IRuleConfigParser createRuleParser() {
return new JsonRuleConfigParser();
}
@Override
public ISystemConfigParser createSystemParser() {
return new JsonSystemConfigParser();
}
}
public class XmlConfigParserFactory implements IConfigParserFactory {
@Override
public IRuleConfigParser createRuleParser() {
return new XmlRuleConfigParser();
}
@Override
public ISystemConfigParser createSystemParser() {
return new XmlSystemConfigParser();
}
}
// 省略YamlConfigParserFactory和PropertiesConfigParserFactory代码
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
重点回顾
三种工厂模式中, 简单工厂和工厂方法比较常用, 抽象工厂的应用场景比较特殊, 所以很少用到, 不是学习的重点. 所以下面重点对前两种工厂模式的应用场景进行总结.
当创建逻辑比较复杂, 是一个 "大工程" 的时候, 就考虑使用工厂模式, 封装对象的创建过程, 将对象的创建和使用相分离. 何为创建逻辑比较复杂呢? 我总结了下面两种情况.
- 第一种情况: 类似规则配置解析的例子, 代码中存在 if-else 分支判断, 动态地根据不同的类型创建不同的对象. 针对这种情况, 就考虑使用工厂模式, 将这一大坨 if-else 创建对象的代码抽离出来, 放到工厂类中.
- 还有一种情况, 尽管不需要根据不同的类型创建不同的对象, 但是单个对象本身的创建过程比较复杂, 比如前面提到的要组合其他类对象, 做各种初始化操作. 在这种情况下, 也可以考虑使用工厂模式, 将对象的创建过程封装到工厂类中.
对于第一种情况, 当每个对象的创建逻辑都比较简单的时候, 推荐使用简单工厂模式, 将多个对象的创建逻辑放到一个工厂类中. 当每个对象的创建逻辑都比较复杂的时候, 为了避免设计一个过于庞大的简单工厂类, 推荐使用工厂方法模式, 将创建逻辑拆分得更细, 每个对象的创建逻辑独立到各自的工厂类中. 同理, 对于第二种情况, 因为单个对象本身的创建逻辑就比较复杂, 所以建议使用工厂方法模式.
除了刚刚提到的这几种情况之外, 如果创建对象的逻辑并不复杂, 那就直接通过 new 来创建对象就可以了, 不需要使用工厂模式.
现在, 上升一个思维层面来看工厂模式, 它的作用无外乎下面这四个. 这也是判断要不要使用工厂模式的最本质的参考标准.
- 封装变化: 创建逻辑有可能变化, 封装成工厂类之后, 创建逻辑的变更对调用者透明.
- 代码复用: 创建代码抽离到独立的工厂类之后可以复用.
- 隔离复杂性: 封装复杂的创建逻辑, 调用者无需了解如何创建对象.
- 控制复杂度: 将创建代码抽离出来, 让原本的函数或类职责更单一, 代码更简洁.
# 45-工厂模式(下):如何设计实现一个Dependency Injection框架(简易Spring)?🌸
上一节讲到, 当创建对象是一个 "大工程" 的时候, 一般会选择使用工厂模式, 来封装对象复杂的创建过程, 将对象的创建和使用分离, 让代码更加清晰. 那何为 "大工程" 呢? 上一节讲了两种情况, 一种是创建过程涉及复杂的 if-else 分支判断, 另一种是对象创建需要组装多个其他类对象或者需要复杂的初始化过程.
今天再来讲一个创建对象的 "大工程", 依赖注入框架, 或者叫依赖注入容器(Dependency Injection Container), 简称 DI 容器. 本节会一块搞清楚这样几个问题: DI 容器跟工厂模式又有何区别和联系? DI 容器的核心功能有哪些, 以及如何实现一个简单的 DI 容器?
# 1.工厂模式和DI容器有何区别?
实际上, DI 容器底层最基本的设计思路就是基于工厂模式的. DI 容器相当于一个大的工厂类, 负责在程序启动的时候, 根据配置(要创建哪些类对象, 每个类对象的创建需要依赖哪些其他类对象)事先创建好对象. 当应用程序需要使用某个类对象的时候, 直接从容器中获取即可. 正是因为它持有一堆对象, 所以这个框架才被称为 "容器".
DI 容器相对于上节讲的工厂模式的例子来说, 它处理的是更大的对象创建工程. 上节讲的工厂模式中, 一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建, 而 DI 容器负责的是整个应用中所有类对象的创建.
除此之外, DI 容器负责的事情要比单纯的工厂模式要多. 比如, 它还包括配置的解析, 对象生命周期的管理. 接下来就详细讲讲, 一个简单的 DI 容器应该包含哪些核心功能.
# 2.DI容器的核心功能有哪些?
总结一下, 一个简单的 DI 容器的核心功能一般有三个: 配置解析, 对象创建和对象生命周期管理.
# (1)配置解析
在上节课讲的工厂模式中, 工厂类要创建哪个类对象是事先确定好的, 并且是写死在工厂类代码中的. 作为一个通用的框架来说, 框架代码跟应用代码应该是高度解耦的, DI 容器事先并不知道应用会创建哪些对象, 不可能把某个应用要创建的对象写死在框架代码中. 所以需要通过一种形式, 让应用告知 DI 容器要创建哪些对象. 这种形式就是这里要讲的配置.
我们将需要由 DI 容器来创建的类对象和创建类对象的必要信息(使用哪个构造函数以及对应的构造函数参数都是什么等等), 放到配置文件中. 容器读取配置文件, 根据配置文件提供的信息来创建对象.
下面是一个典型的 Spring 容器的配置文件. Spring 容器读取这个配置文件, 解析出要创建的两个对象: rateLimiter 和 redisCounter, 并且得到两者的依赖关系: rateLimiter 依赖 redisCounter.
public class RateLimiter {
private RedisCounter redisCounter;
public RateLimiter(RedisCounter redisCounter) {
this.redisCounter = redisCounter;
}
public void test() {
System.out.println("Hello World!");
}
//...
}
public class RedisCounter {
private String ipAddress;
private int port;
public RedisCounter(String ipAddress, int port) {
this.ipAddress = ipAddress;
this.port = port;
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
配置文件beans.xml:
<beans>
<bean id="rateLimiter" class="com.xzg.RateLimiter">
<constructor-arg ref="redisCounter"/>
</bean>
<bean id="redisCounter" class="com.xzg.redisCounter">
<constructor-arg type="String" value="127.0.0.1">
<constructor-arg type="int" value=1234>
</bean>
</beans>
2
3
4
5
6
7
8
9
10
# (2)对象创建
在 DI 容器中, 如果给每个类都对应创建一个工厂类, 那项目中类的个数会成倍增加, 这会增加代码的维护成本. 要解决这个问题并不难. 只需要将所有类对象的创建都放到一个工厂类中完成就可以了, 比如 BeansFactory.
你可能会说, 如果要创建的类对象非常多, BeansFactory 中的代码会不会线性膨胀(代码量跟创建对象的个数成正比)呢? 实际上并不会. 待会讲到 DI 容器的具体实现的时候, 会讲 "反射" 这种机制, 它能在程序运行的过程中, 动态地加载类, 创建对象, 不需要事先在代码中写死要创建哪些对象. 所以不管是创建一个对象还是十个对象, BeansFactory 工厂类代码都是一样的.
# (3)对象的生命周期管理
上一节讲到, 简单工厂模式有两种实现方式, 一种是每次都返回新创建的对象, 另一种是每次都返回同一个事先创建好的对象, 也就是所谓的单例对象. 在 Spring 框架中, 可以通过配置 scope 属性, 来区分这两种不同类型的对象. scope=prototype 表示返回新创建的对象, scope=singleton 表示返回单例对象.
除此之外, 还可以配置对象是否支持懒加载. 如果 lazy-init=true, 对象在真正被使用到的时候(比如: BeansFactory.getBean("userService")) 才被被创建; 如果 lazy-init=false, 对象在应用启动的时候就事先创建好.
不仅如此, 还可以配置对象的 init-method 和 destroy-method 方法, 比如 init-method=loadProperties(), destroy-method=updateConfigFile(). DI 容器在创建好对象之后, 会主动调用 init-method 属性指定的方法来初始化对象. 在对象被最终销毁之前, DI 容器会主动调用 destroy-method 属性指定的方法来做一些清理工作, 比如释放数据库连接池, 关闭文件.
# 3.如何实现一个简单的DI容器?
实际上, 用 Java 语言来实现一个简单的 DI 容器, 核心逻辑只需要包括这样两个部分: 配置文件解析, 根据配置文件通过 "反射" 语法来创建对象.
# (1)最小原型设计
因为主要是讲解设计模式, 所以今天的讲解中, 只实现一个 DI 容器的最小原型. 像 Spring 框架这样的 DI 容器, 它支持的配置格式非常灵活和复杂. 为了简化代码实现, 重点讲解原理, 在最小原型中, 只支持下面配置文件中涉及的配置语法.
配置文件beans.xml
<beans>
<bean id="rateLimiter" class="com.xzg.RateLimiter">
<constructor-arg ref="redisCounter"/>
</bean>
<bean id="redisCounter" class="com.xzg.redisCounter" scope="singleton" lazy-init="true">
<constructor-arg type="String" value="127.0.0.1">
<constructor-arg type="int" value=1234>
</bean>
</beans>
2
3
4
5
6
7
8
9
10
最小原型的使用方式跟 Spring 框架非常类似, 示例代码如下所示:
public class Demo {
public static void main(String[] args) {
ApplicationContext applicationContext = new ClassPathXmlApplicationContext(
"beans.xml");
RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
rateLimiter.test();
// ...
}
}
2
3
4
5
6
7
8
9
# (2)提供执行入口
前面讲到, 面向对象设计的最后一步是: 组装类并提供执行入口. 在这里, 执行入口就是一组暴露给外部使用的接口和类.
通过刚刚的最小原型使用示例代码, 可以看出, 执行入口主要包含两部分: ApplicationContext 和 ClassPathXmlApplicationContext. 其中, ApplicationContext 是接口, ClassPathXmlApplicationContext 是接口的实现类. 两个类具体实现如下所示:
public interface ApplicationContext {
Object getBean(String beanId);
}
2
3
public class ClassPathXmlApplicationContext implements ApplicationContext {
private BeansFactory beansFactory;
private BeanConfigParser beanConfigParser;
public ClassPathXmlApplicationContext(String configLocation) {
this.beansFactory = new BeansFactory();
this.beanConfigParser = new XmlBeanConfigParser();
loadBeanDefinitions(configLocation);
}
private void loadBeanDefinitions(String configLocation) {
InputStream in = null;
try {
in = this.getClass().getResourceAsStream("/" + configLocation);
if (in == null) {
throw new RuntimeException("Can not find config file: " + configLocation);
}
List<BeanDefinition> beanDefinitions = beanConfigParser.parse(in);
beansFactory.addBeanDefinitions(beanDefinitions);
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
// TODO: log error
}
}
}
}
@Override
public Object getBean(String beanId) {
return beansFactory.getBean(beanId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
从上面的代码中, 可以看出, ClassPathXmlApplicationContext 负责组装 BeansFactory 和 BeanConfigParser 两个类, 串联执行流程: 从 classpath 中加载 XML 格式的配置文件, 通过 BeanConfigParser 解析为统一的 BeanDefinition 格式, 然后, BeansFactory 根据 BeanDefinition 来创建对象.
# (3)配置文件解析
配置文件解析主要包含 BeanConfigParser 接口和 XmlBeanConfigParser 实现类, 负责将配置文件解析为 BeanDefinition 结构, 以便 BeansFactory 根据这个结构来创建对象.
配置文件的解析比较繁琐, 不涉及专栏要讲的理论知识, 不是讲解的重点, 所以这里只给出两个类的大致设计思路, 并未给出具体的实现代码. 如果感兴趣的话, 可以自行补充完整. 具体的代码框架如下所示:
public interface BeanConfigParser {
List<BeanDefinition> parse(InputStream inputStream);
List<BeanDefinition> parse(String configContent);
}
public class XmlBeanConfigParser implements BeanConfigParser {
@Override
public List<BeanDefinition> parse(InputStream inputStream) {
String content = null;
// TODO:...
return parse(content);
}
@Override
public List<BeanDefinition> parse(String configContent) {
List<BeanDefinition> beanDefinitions = new ArrayList<>();
// TODO:...
return beanDefinitions;
}
}
public class BeanDefinition {
private String id;
private String className;
private List<ConstructorArg> constructorArgs = new ArrayList<>();
private Scope scope = Scope.SINGLETON;
private boolean lazyInit = false;
// 省略必要的getter/setter/constructors
public boolean isSingleton() {
return scope.equals(Scope.SINGLETON);
}
public static enum Scope {
SINGLETON,
PROTOTYPE
}
public static class ConstructorArg {
private boolean isRef;
private Class type;
private Object arg;
// 省略必要的getter/setter/constructors
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# (4)核心工厂类设计
最后来看, BeansFactory 是如何设计和实现的. 这也是这个 DI 容器最核心的一个类了. 它负责根据从配置文件解析得到的 BeanDefinition 来创建对象.
如果对象的 scope 属性是 singleton, 那对象创建之后会缓存在 singletonObjects 这样一个 map 中, 下次再请求此对象的时候, 直接从 map 中取出返回, 不需要重新创建. 如果对象的 scope 属性是 prototype, 那每次请求对象, BeansFactory 都会创建一个新的对象返回.
实际上, BeansFactory 创建对象用到的主要技术点就是 Java 中的反射语法: 一种动态加载类和创建对象的机制. JVM 在启动的时候会根据代码自动地加载类, 创建对象. 至于都要加载哪些类, 创建哪些对象, 这些都是在代码中写死的, 或者说提前写好的. 但如果某个对象的创建并不是写死在代码中, 而是放到配置文件中, 需要在程序运行期间, 动态地根据配置文件来加载类, 创建对象, 那这部分工作就没法让 JVM 帮我们自动完成了, 需要利用 Java 提供的反射语法自己去编写代码.
搞清楚了反射的原理, BeansFactory 的代码就不难看懂了. 具体代码实现如下所示:
public class BeansFactory {
private ConcurrentHashMap<String, Object> singletonObjects = new ConcurrentHashMap<>();
private ConcurrentHashMap<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>();
public void addBeanDefinitions(List<BeanDefinition> beanDefinitionList) {
for (BeanDefinition beanDefinition : beanDefinitionList) {
this.beanDefinitions.putIfAbsent(beanDefinition.getId(), beanDefinition);
}
for (BeanDefinition beanDefinition : beanDefinitionList) {
if (beanDefinition.isLazyInit() == false && beanDefinition.isSingleton()) {
createBean(beanDefinition);
}
}
}
public Object getBean(String beanId) {
BeanDefinition beanDefinition = beanDefinitions.get(beanId);
if (beanDefinition == null) {
throw new NoSuchBeanDefinitionException("Bean is not defined: " + beanId);
}
return createBean(beanDefinition);
}
@VisibleForTesting
protected Object createBean(BeanDefinition beanDefinition) {
if (beanDefinition.isSingleton() && singletonObjects.contains(beanDefinition.getId())) {
return singletonObjects.get(beanDefinition.getId());
}
Object bean = null;
try {
Class beanClass = Class.forName(beanDefinition.getClassName());
List<BeanDefinition.ConstructorArg> args = beanDefinition.getConstructorArgs();
if (args.isEmpty()) {
bean = beanClass.newInstance();
} else {
Class[] argClasses = new Class[args.size()];
Object[] argObjects = new Object[args.size()];
for (int i = 0; i < args.size(); ++i) {
BeanDefinition.ConstructorArg arg = args.get(i);
if (!arg.getIsRef()) {
argClasses[i] = arg.getType();
argObjects[i] = arg.getArg();
} else {
BeanDefinition refBeanDefinition = beanDefinitions.get(arg.getArg());
if (refBeanDefinition == null) {
throw new NoSuchBeanDefinitionException("Bean is not defined: " + arg.getArg());
}
argClasses[i] = Class.forName(refBeanDefinition.getClassName());
argObjects[i] = createBean(refBeanDefinition);
}
}
// 反射创建bean
bean = beanClass.getConstructor(argClasses).newInstance(argObjects);
}
} catch (ClassNotFoundException | IllegalAccessException
| InstantiationException | NoSuchMethodException | InvocationTargetException e) {
throw new BeanCreationFailureException("", e);
}
if (bean != null && beanDefinition.isSingleton()) {
singletonObjects.putIfAbsent(beanDefinition.getId(), bean);
return singletonObjects.get(beanDefinition.getId());
}
return bean;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
重点回顾
DI 容器在一些软件开发中已经成为了标配, 比如 Spring IOC, Google Guice. 但大部分人可能只是把它当作一个黑盒子来使用, 并未真正去了解它的底层是如何实现的. 当然, 如果只是做一些简单的小项目, 简单会用就足够了, 但如果面对的是非常复杂的系统, 当系统出现问题的时候, 对底层原理的掌握程度, 决定了我们排查问题的能力, 直接影响到排查问题的效率.
本节讲解了一个简单的 DI 容器的实现原理, 其核心逻辑主要包括: 配置文件解析, 以及根据配置文件通过 "反射" 语法来创建对象. 其中, 创建对象的过程就应用到了工厂模式. 对象创建, 组装, 管理完全有 DI 容器来负责, 跟具体业务代码解耦, 让程序员聚焦在业务代码的开发上.
# 46-建造者模式:详解构造函数,set方法,建造者模式三种对象创建方式
本节学习一个比较常用的创建型设计模式, Builder 模式, 中文翻译为建造者模式或者构建者模式, 也有人叫它生成器模式.
实际上, 建造者模式的原理和代码实现非常简单, 掌握起来并不难, 难点在于应用场景. 比如, 你有没有考虑过这样几个问题: 直接使用构造函数或者配合 set 方法就能创建对象, 为什么还需要建造者模式来创建呢? 建造者模式和工厂模式都可以创建对象, 那它们两个的区别在哪里呢?
# 1.为什么需要建造者模式?
在平时的开发中, 创建一个对象最常用的方式是, 使用 new 关键字调用类的构造函数来完成. 那问题是, 什么情况下这种方式就不适用了, 就需要采用建造者模式来创建对象呢?
假设有这样一道设计面试题: 需要定义一个资源池配置类 ResourcePoolConfig. 这里的资源池, 可以简单理解为线程池, 连接池, 对象池等. 在这个资源池配置类中, 有以下几个成员变量, 也就是可配置项. 现在请编写代码实现这个 ResourcePoolConfig 类.

最常见, 最容易想到的实现思路如下代码所示. 因为 maxTotal, maxIdle, minIdle 不是必填变量, 所以在创建 ResourcePoolConfig 对象的时候, 通过往构造函数中, 给这几个参数传递 null 值, 来表示使用默认值.
public class ResourcePoolConfig {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;
private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;
public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("name should not be empty.");
}
this.name = name;
if (maxTotal != null) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("maxTotal should be positive.");
}
this.maxTotal = maxTotal;
}
if (maxIdle != null) {
if (maxIdle < 0) {
throw new IllegalArgumentException("maxIdle should not be negative.");
}
this.maxIdle = maxIdle;
}
if (minIdle != null) {
if (minIdle < 0) {
throw new IllegalArgumentException("minIdle should not be negative.");
}
this.minIdle = minIdle;
}
}
//...省略getter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
现在, ResourcePoolConfig 只有 4 个可配置项, 对应到构造函数中, 也只有 4 个参数, 参数的个数不多. 但是, 如果可配置项逐渐增多, 变成了 8 个, 10 个, 甚至更多, 那继续沿用现在的设计思路, 构造函数的参数列表会变得很长, 代码在可读性和易用性上都会变差. 在使用构造函数的时候, 就容易搞错各参数的顺序, 传递进错误的参数值, 导致非常隐蔽的 bug.
// 参数太多, 导致可读性差, 参数可能传递错误
ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20, false, true);
2
解决这个问题的办法你应该也已经想到了, 那就是用 set() 函数来给成员变量赋值, 以替代冗长的构造函数. 直接看代码, 具体如下所示. 其中, 配置项 name 是必填的, 所以把它放到构造函数中设置, 强制创建类对象的时候就要填写. 其他配置项 maxTotal, maxIdle, minIdle 都不是必填的, 所以通过 set() 函数来设置, 让使用者自主选择填写或者不填写.
public class ResourcePoolConfig {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;
private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;
public ResourcePoolConfig(String name) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("name should not be empty.");
}
this.name = name;
}
public void setMaxTotal(int maxTotal) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("maxTotal should be positive.");
}
this.maxTotal = maxTotal;
}
public void setMaxIdle(int maxIdle) {
if (maxIdle < 0) {
throw new IllegalArgumentException("maxIdle should not be negative.");
}
this.maxIdle = maxIdle;
}
public void setMinIdle(int minIdle) {
if (minIdle < 0) {
throw new IllegalArgumentException("minIdle should not be negative.");
}
this.minIdle = minIdle;
}
// ...省略getter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
接下来看新的 ResourcePoolConfig 类该如何使用. 示例代码, 如下所示. 没有了冗长的函数调用和参数列表, 代码在可读性和易用性上提高了很多.
// ResourcePoolConfig使用举例
ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool");
config.setMaxTotal(16);
config.setMaxIdle(8);
2
3
4
至此, 仍然没有用到建造者模式, 通过构造函数设置必填项, 通过 set() 方法设置可选配置项, 就能实现设计需求. 如果把问题的难度再加大点, 比如还需要解决下面这三个问题, 那现在的设计思路就不能满足了.
- 刚刚讲到, name 是必填的, 所以把它放到构造函数中, 强制创建对象的时候就设置. 如果必填的配置项有很多, 把这些必填配置项都放到构造函数中设置, 那构造函数就又会出现参数列表很长的问题. 如果把必填项也通过 set() 方法设置, 那校验这些必填项是否已经填写的逻辑就无处安放了.
- 除此之外, 假设配置项之间有一定的依赖关系, 比如, 如果用户设置了 maxTotal, maxIdle, minIdle 其中一个, 就必须显式地设置另外两个; 或者配置项之间有一定的约束条件, 比如, maxIdle 和 minIdle 要小于等于 maxTotal. 如果继续使用现在的设计思路, 那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了.
- 如果希望 ResourcePoolConfig 类对象是不可变对象, 也就是说, 对象在创建好之后, 就不能再修改内部的属性值. 要实现这个功能, 就不能在 ResourcePoolConfig 类中暴露 set() 方法.
为了解决这些问题, 建造者模式就派上用场了.
可以把校验逻辑放置到 Builder 类中, 先创建建造者, 并且通过 set() 方法设置建造者的变量值, 然后在使用 build() 方法真正创建对象之前, 做集中的校验, 校验通过之后才会创建对象. 除此之外, 把 ResourcePoolConfig 的构造函数改为 private 私有权限. 这样就只能通过建造者来创建 ResourcePoolConfig 类对象. 并且, ResourcePoolConfig 没有提供任何 set() 方法, 这样创建出来的对象就是不可变对象了.
用建造者模式重新实现了上面的需求, 具体的代码如下所示:
public class ResourcePoolConfig {
private String name;
private int maxTotal;
private int maxIdle;
private int minIdle;
private ResourcePoolConfig(Builder builder) {
this.name = builder.name;
this.maxTotal = builder.maxTotal;
this.maxIdle = builder.maxIdle;
this.minIdle = builder.minIdle;
}
//...省略getter方法...
// 我们将Builder类设计成了ResourcePoolConfig的内部类.
// 我们也可以将Builder类设计成独立的非内部类ResourcePoolConfigBuilder.
public static class Builder {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;
private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;
public ResourcePoolConfig build() {
// 校验逻辑放到这里来做, 包括必填项校验, 依赖关系校验, 约束条件校验等
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("...");
}
if (maxIdle > maxTotal) {
throw new IllegalArgumentException("...");
}
if (minIdle > maxTotal || minIdle > maxIdle) {
throw new IllegalArgumentException("...");
}
return new ResourcePoolConfig(this);
}
public Builder setName(String name) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("...");
}
this.name = name;
return this;
}
public Builder setMaxTotal(int maxTotal) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("...");
}
this.maxTotal = maxTotal;
return this;
}
public Builder setMaxIdle(int maxIdle) {
if (maxIdle < 0) {
throw new IllegalArgumentException("...");
}
this.maxIdle = maxIdle;
return this;
}
public Builder setMinIdle(int minIdle) {
if (minIdle < 0) {
throw new IllegalArgumentException("...");
}
this.minIdle = minIdle;
return this;
}
}
}
// 这段代码会抛出IllegalArgumentException, 因为minIdle>maxIdle
ResourcePoolConfig config = new ResourcePoolConfig.Builder()
.setName("dbconnectionpool")
.setMaxTotal(16)
.setMaxIdle(10)
.setMinIdle(12)
.build();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
实际上, 使用建造者模式创建对象, 还能避免对象存在无效状态. 举个例子解释一下. 比如定义了一个长方形类, 如果不使用建造者模式, 采用先创建后 set 的方式, 那就会导致在第一个 set 之后, 对象处于无效状态. 具体代码如下所示:
Rectangle r = new Rectange(); // r is invalid
r.setWidth(2); // r is invalid
r.setHeight(3); // r is valid
2
3
为了避免这种无效状态的存在, 就需要使用构造函数一次性初始化好所有的成员变量. 如果构造函数参数过多, 就需要考虑使用建造者模式, 先设置建造者的变量, 然后再一次性地创建对象, 让对象一直处于有效状态.
实际上, 如果并不是很关心对象是否有短暂的无效状态, 也不是太在意对象是否是可变的. 比如, 对象只是用来映射数据库读出来的数据, 那直接暴露 set() 方法来设置类的成员变量值是完全没问题的. 而且使用建造者模式来构建对象, 代码实际上是有点重复的, ResourcePoolConfig 类中的成员变量, 要在 Builder 类中重新再定义一遍.
# 2.与工厂模式有何区别?
从上面的讲解中可以看出, 建造者模式是让建造者类来负责对象的创建工作. 上一节讲到的工厂模式, 是由工厂类来负责对象创建的工作. 那它们之间有什么区别呢?
实际上, 工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类), 由给定的参数来决定创建哪种类型的对象. 建造者模式是用来创建一种类型的复杂对象, 通过设置不同的可选参数, "定制化" 地创建不同的对象.
网上有一个经典的例子很好地解释了两者的区别.
顾客走进一家餐馆点餐, 利用工厂模式, 根据用户不同的选择, 来制作不同的食物, 比如披萨, 汉堡, 沙拉. 对于披萨来说, 用户又有各种配料可以定制, 比如奶酪, 西红柿, 起司, 通过建造者模式根据用户选择的不同配料来制作披萨.
实际上, 也不要太学院派, 非得把工厂模式, 建造者模式分得那么清楚, 需要知道的是, 每个模式为什么这么设计, 能解决什么问题. **只有了解了这些最本质的东西, 才能不生搬硬套, 才能灵活应用, 甚至可以混用各种模式创造出新的模式, 来解决特定场景的问题. **
重点回顾
建造者模式的原理和实现比较简单, 重点是掌握应用场景, 避免过度使用.
如果一个类中有很多属性, 为了避免构造函数的参数列表过长, 影响代码的可读性和易用性, 可以通过构造函数配合 set() 方法来解决. 但如果存在下面情况中的任意一种, 就要考虑使用建造者模式了.
- 把类的必填属性放到构造函数中, 强制创建对象的时候就设置. 如果必填的属性有很多, 把这些必填属性都放到构造函数中设置, 那构造函数就又会出现参数列表很长的问题. 如果把必填属性通过 set() 方法设置, 那校验这些必填属性是否已经填写的逻辑就无处安放了.
- 如果类的属性之间有一定的依赖关系或者约束条件, 继续使用构造函数配合 set() 方法的设计思路, 那这些依赖关系或约束条件的校验逻辑就无处安放了.
- 如果希望创建不可变对象, 也就是说, 对象在创建好之后, 就不能再修改内部的属性值, 要实现这个功能, 就不能在类中暴露 set() 方法. 构造函数配合 set() 方法来设置属性值的方式就不适用了.
除此之外, 还对比了工厂模式和建造者模式的区别. 工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类), 由给定的参数来决定创建哪种类型的对象. 建造者模式是用来创建一种类型的复杂对象, 可以通过设置不同的可选参数, "定制化" 地创建不同的对象.
# 47-原型模式:如何最快速地clone一个HashMap散列表?
对于创建型模式, 今天来讲最后一个: 原型模式.
对于熟悉 JavaScript 语言的前端程序员来说, 原型模式是一种比较常用的开发模式. 这是因为, 有别于 Java, C++ 等基于类的面向对象编程语言, JavaScript 是一种基于原型的面向对象编程语言. 即便 JavaScript 现在也引入了类的概念, 但它也只是基于原型的语法糖而已. 不过, 如果你熟悉的是 Java, C++ 等这些编程语言, 那在实际的开发中, 就很少用到原型模式了.
今天的讲解跟具体某一语言的语法机制无关, 而是通过一个 clone 散列表的例子带你搞清楚: 原型模式的应用场景, 以及它的两种实现方式: 深拷贝和浅拷贝. 虽然原型模式的原理和代码实现非常简单, 但今天举的例子还是稍微有点复杂的.
# 1.原型模式的原理与应用
如果对象的创建成本比较大, 而同一个类的不同对象之间差别不大(大部分字段都相同), 在这种情况下, 可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象, 以达到节省创建时间的目的. 这种基于原型来创建对象的方式就叫作原型设计模式(Prototype Design Pattern), 简称原型模式.
**那何为 "对象的创建成本比较大"? **
实际上, 创建对象包含的申请内存, 给成员变量赋值这一过程, 本身并不会花费太多时间, 或者说对于大部分业务系统来说, 这点时间完全是可以忽略的. 应用一个复杂的模式, 只得到一点点的性能提升, 这就是所谓的过度设计, 得不偿失.
但如果对象中的数据需要经过复杂的计算才能得到(比如排序, 计算哈希值), 或者需要从 RPC, 网络, 数据库, 文件系统等非常慢速的 IO 中读取, 这种情况下, 就可以利用原型模式, 从其他已有对象中直接拷贝得到, 而不用每次在创建新对象的时候, 都重复执行这些耗时的操作.
**这么说还是比较理论, 接下来通过一个例子来解释一下. **
假设数据库中存储了大约 10 万条 "搜索关键词" 信息, 每条信息包含关键词, 关键词被搜索的次数, 信息最近被更新的时间等. 系统 A 在启动的时候会加载这份数据到内存中, 用于处理某些其他的业务需求. 为了方便快速地查找某个关键词对应的信息, 给关键词建立一个散列表索引.
如果你熟悉的是 Java 语言, 可以直接使用语言中提供的 HashMap 容器来实现. 其中, HashMap 的 key 为搜索关键词, value 为关键词详细信息(比如搜索次数). 只需要将数据从数据库中读取出来, 放入 HashMap 就可以了.
不过, 还有另外一个系统 B, 专门用来分析搜索日志, 定期(比如间隔 10 分钟)批量地更新数据库中的数据, 并且标记为新的数据版本. 比如在下面的示例图中, 对 v2 版本的数据进行更新, 得到 v3 版本的数据. 这里假设只有更新和新添关键词, 没有删除关键词的行为.

为了保证系统 A 中数据的实时性(不一定非常实时, 但数据也不能太旧), 系统 A 需要定期根据数据库中的数据, 更新内存中的索引和数据.
该如何实现这个需求呢?
实际上, 也不难. 只需要在系统 A 中, 记录当前数据的版本 Va 对应的更新时间 Ta, 从数据库中捞出更新时间大于 Ta 的所有搜索关键词, 也就是找出 Va 版本与最新版本数据的 "差集", 然后针对差集中的每个关键词进行处理. 如果它已经在散列表中存在了, 就更新相应的搜索次数, 更新时间等信息; 如果它在散列表中不存在, 就将它插入到散列表中.
按照这个设计思路, 给出的示例代码如下所示:
public class Demo {
private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>();
private long lastUpdateTime = -1;
public void refresh() {
// 从数据库中取出更新时间>lastUpdateTime的数据, 放入到currentKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (currentKeywords.containsKey(searchWord.getKeyword())) {
currentKeywords.replace(searchWord.getKeyword(), searchWord);
} else {
currentKeywords.put(searchWord.getKeyword(), searchWord);
}
}
lastUpdateTime = maxNewUpdatedTime;
}
private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
不过, 现在有一个特殊的要求: 任何时刻, 系统 A 中的所有数据都必须是同一个版本的, 要么都是版本 a, 要么都是版本 b, 不能有的是版本 a, 有的是版本 b. 那刚刚的更新方式就不能满足这个要求了. 除此之外还要求: 在更新内存数据的时候, 系统 A 不能处于不可用状态, 也就是不能停机更新数据.
那该如何实现现在这个需求呢?
实际上, 也不难. 把正在使用的数据的版本定义为 "服务版本", 当要更新内存中的数据的时候, 并不是直接在服务版本(假设是版本 a 数据)上更新, 而是重新创建另一个版本数据(假设是版本 b 数据), 等新的版本数据建好之后, 再一次性地将服务版本从版本 a 切换到版本 b. 这样既保证了数据一直可用, 又避免了中间状态的存在.
按照这个设计思路, 给出的示例代码如下所示:
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
public void refresh() {
HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();
// 从数据库中取出所有的数据, 放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords();
for (SearchWord searchWord : toBeUpdatedSearchWords) {
newKeywords.put(searchWord.getKeyword(), searchWord);
}
currentKeywords = newKeywords;
}
private List<SearchWord> getSearchWords() {
// TODO: 从数据库中取出所有的数据
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
不过, 在上面的代码实现中, newKeywords 构建的成本比较高. 需要将这 10 万条数据从数据库中读出, 然后计算哈希值, 构建 newKeywords. 这个过程显然是比较耗时. 为了提高效率, 原型模式就派上用场了.
拷贝 currentKeywords 数据到 newKeywords 中, 然后从数据库中只捞出新增或者有更新的关键词, 更新到 newKeywords 中. 而相对于 10 万条数据来说, 每次新增或者更新的关键词个数是比较少的, 所以这种策略大大提高了数据更新的效率.
按照这个设计思路, 给出的示例代码如下所示:
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;
public void refresh() {
// 原型模式就这么简单, 拷贝已有对象的数据, 更新少量差值
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
// 从数据库中取出更新时间>lastUpdateTime的数据, 放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
oldSearchWord.setCount(searchWord.getCount());
oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
} else {
newKeywords.put(searchWord.getKeyword(), searchWord);
}
}
lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}
private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
这里利用了 Java 中的 clone() 语法来复制一个对象. 如果你熟悉的语言没有这个语法, 那把数据从 currentKeywords 中一个个取出来, 然后再重新计算哈希值, 放入到 newKeywords 中也是可以接受的. 毕竟, 最耗时的还是从数据库中取数据的操作. 相对于数据库的 IO 操作来说, 内存操作和 CPU 计算的耗时都是可以忽略的.
不过, 不知道你有没有发现, 实际上, 刚刚的代码实现是有问题的. 要弄明白到底有什么问题, 需要先了解另外两个概念: 深拷贝(Deep Copy)和浅拷贝(Shallow Copy) .
# 2.原型模式的实现方式:深拷贝和浅拷贝
先看看在内存中, 用散列表组织的搜索关键词信息是如何存储的. 其示意图大致结构如下所示. 从图中可以发现, 散列表索引中, 每个结点存储的 key 是搜索关键词, value 是 SearchWord 对象的内存地址. SearchWord 对象本身存储在散列表之外的内存空间中.

浅拷贝和深拷贝的区别在于, 浅拷贝只会复制图中的索引(散列表), 不会复制数据(SearchWord 对象)本身. 相反, 深拷贝不仅仅会复制索引, 还会复制数据本身. 浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord 对象), 而深拷贝得到的是一份完完全全独立的对象. 具体的对比如下图所示:


在 Java 语言中, Object 类的 clone() 方法执行的就是刚刚说的浅拷贝. 它只会拷贝对象中的基本数据类型的数据(比如, int, long), 以及引用对象(SearchWord)的内存地址, 不会递归地拷贝引用对象本身.
在上面的代码中, 通过调用 HashMap 上的 clone() 浅拷贝方法来实现原型模式. 当通过 newKeywords 更新 SearchWord 对象的时候(比如, 更新 "设计模式" 这个搜索关键词的访问次数), newKeywords 和 currentKeywords 因为指向相同的一组 SearchWord 对象, 就会导致 currentKeywords 中指向的 SearchWord, 有的是老版本的, 有的是新版本的, 就没法满足之前的需求: currentKeywords 中的数据在任何时刻都是同一个版本的, 不存在介于老版本与新版本之间的中间状态.
现在又该如何来解决这个问题呢?
可以将浅拷贝替换为深拷贝. newKeywords 不仅仅复制 currentKeywords 的索引, 还把 SearchWord 对象也复制一份出来, 这样 newKeywords 和 currentKeywords 就指向不同的 SearchWord 对象, 也就不存在更新 newKeywords 的数据会导致 currentKeywords 的数据也被更新的问题了.
那如何实现深拷贝呢? 总结一下的话, 有下面两种方法.
第一种方法: 递归拷贝对象, 对象的引用对象以及引用对象的引用对象... 直到要拷贝的对象只包含基本数据类型数据, 没有引用对象为止. 根据这个思路对之前的代码进行重构. 重构之后的代码如下所示:
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;
public void refresh() {
// Deep copy
HashMap<String, SearchWord> newKeywords = new HashMap<>();
for (HashMap.Entry<String, SearchWord> e : currentKeywords.entrySet()) {
SearchWord searchWord = e.getValue();
SearchWord newSearchWord = new SearchWord(
searchWord.getKeyword(), searchWord.getCount(), searchWord.getLastUpdateTime());
newKeywords.put(e.getKey(), newSearchWord);
}
// 从数据库中取出更新时间>lastUpdateTime的数据, 放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
oldSearchWord.setCount(searchWord.getCount());
oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
} else {
newKeywords.put(searchWord.getKeyword(), searchWord);
}
}
lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}
private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
第二种方法: 先将对象序列化, 然后再反序列化成新的对象. 具体的示例代码如下所示:
public Object deepCopy(Object object) {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
ObjectOutputStream oo = new ObjectOutputStream(bo);
oo.writeObject(object);
ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
ObjectInputStream oi = new ObjectInputStream(bi);
return oi.readObject();
}
2
3
4
5
6
7
8
9
10
刚刚的两种实现方法, 不管采用哪种, 深拷贝都要比浅拷贝耗时, 耗内存空间. 针对这个应用场景, 有没有更快, 更省内存的实现方式呢?
可以先采用浅拷贝的方式创建 newKeywords. 对于需要更新的 SearchWord 对象, 再使用深度拷贝的方式创建一份新的对象, 替换 newKeywords 中的老对象. 毕竟需要更新的数据是很少的. 这种方式即利用了浅拷贝节省时间, 空间的优点, 又能保证 currentKeywords 中的中数据都是老版本的数据. 具体的代码实现如下所示. 这也是标题中讲到的, 在这个应用场景下, 最快速 clone 散列表的方式.
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;
public void refresh() {
// Shallow copy
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
// 从数据库中取出更新时间>lastUpdateTime的数据, 放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
newKeywords.remove(searchWord.getKeyword());
}
newKeywords.put(searchWord.getKeyword(), searchWord);
}
lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}
private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
重点回顾
**1.什么是原型模式? **
如果对象的创建成本比较大, 而同一个类的不同对象之间差别不大(大部分字段都相同), 在这种情况下, 可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式, 来创建新对象, 以达到节省创建时间的目的. 这种基于原型来创建对象的方式就叫作原型设计模式, 简称原型模式.
2.原型模式的两种实现方法
原型模式有两种实现方法, 深拷贝和浅拷贝. 浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址, 不会递归地复制引用对象, 以及引用对象的引用对象... 而深拷贝得到的是一份完完全全独立的对象. 所以, 深拷贝比起浅拷贝来说, 更加耗时, 更加耗内存空间.
如果要拷贝的对象是不可变对象, 浅拷贝共享不可变对象是没问题的, 但对于可变对象来说, 浅拷贝得到的对象和原始对象会共享部分数据, 就有可能出现数据被修改的风险, 也就变得复杂多了. 除非像实战中举的那个例子, 需要从数据库中加载 10 万条数据并构建散列表索引, 操作非常耗时, 比较推荐使用浅拷贝, 否则没有充分的理由, 不要为了一点点的性能提升而使用浅拷贝.
# 48-代理模式:代理在RPC,缓存,监控等场景中的应用
前面几节学习了设计模式中的创建型模式. 创建型模式主要解决对象的创建问题, 封装复杂的创建过程, 解耦对象的创建代码和使用代码. 其中, 单例模式用来创建全局唯一的对象. 工厂模式用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类), 由给定的参数来决定创建哪种类型的对象. 建造者模式是用来创建复杂对象, 可以通过设置不同的可选参数, "定制化"地创建不同的对象. 原型模式针对创建成本比较大的对象, 利用对已有对象进行复制的方式进行创建, 以达到节省创建时间的目的.
从今天起,开始学习另外一种类型的设计模式: 结构型模式. 结构型模式主要总结了一些类或对象组合在一起的经典结构, 这些经典的结构可以解决特定应用场景的问题. 结构型模式包括: 代理模式, 桥接模式, 装饰器模式, 适配器模式, 门面模式, 组合模式, 享元模式.
今天要讲其中的代理模式. 它也是在实际开发中经常被用到的一种设计模式.
# 1.代理模式的原理解析
代理模式(Proxy Design Pattern)在不改变原始类(或叫被代理类)代码的情况下, 通过引入代理类来给原始类附加功能. 举个例子来解释一下.
这个例子来自我前面讲的性能计数器. 当时开发了一个 MetricsCollector 类, 用来收集接口请求的原始数据, 比如访问时间, 处理时长等. 在业务系统中, 采用如下方式来使用这个 MetricsCollector 类:
public class UserController {
//...省略其他属性和方法...
private MetricsCollector metricsCollector; // 依赖注入
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
// ... 省略login逻辑...
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
//...返回UserVo数据...
}
public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
// ... 省略register逻辑...
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
//...返回UserVo数据...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
很明显, 上面的写法有两个问题. 第一, 性能计数器框架代码侵入到业务代码中, 跟业务代码高度耦合. 如果未来需要替换这个框架, 那替换的成本会比较大. 第二, 收集接口请求的代码跟业务代码无关, 本就不应该放到一个类中. 业务类最好职责更加单一, 只聚焦业务处理.
为了将框架代码和业务代码解耦, 代理模式就派上用场了. 代理类 UserControllerProxy 和原始类 UserController 实现相同的接口 IUserController. UserController 类只负责业务功能. 代理类 UserControllerProxy 负责在业务代码执行前后附加其他逻辑代码, 并通过委托的方式调用原始类来执行业务代码. 具体的代码实现如下所示:
public interface IUserController {
UserVo login(String telephone, String password);
UserVo register(String telephone, String password);
}
public class UserController implements IUserController {
//...省略其他属性和方法...
@Override
public UserVo login(String telephone, String password) {
//...省略login逻辑...
//...返回UserVo数据...
}
@Override
public UserVo register(String telephone, String password) {
//...省略register逻辑...
//...返回UserVo数据...
}
}
public class UserControllerProxy implements IUserController {
private MetricsCollector metricsCollector;
private UserController userController;
public UserControllerProxy(UserController userController) {
this.userController = userController;
this.metricsCollector = new MetricsCollector();
}
@Override
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
// 委托
UserVo userVo = userController.login(telephone, password);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return userVo;
}
@Override
public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
UserVo userVo = userController.register(telephone, password);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return userVo;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// UserControllerProxy使用举例
// 因为原始类和代理类实现相同的接口, 是基于接口而非实现编程
// 将UserController类对象替换为UserControllerProxy类对象, 不需要改动太多代码
IUserController userController = new UserControllerProxy(new UserController());
2
3
4
参照基于接口而非实现编程的设计思想, 将原始类对象替换为代理类对象的时候, 为了让代码改动尽量少, 在刚刚的代理模式的代码实现中, 代理类和原始类需要实现相同的接口. 但是, 如果原始类并没有定义接口, 并且原始类代码并不是自己开发维护的(比如它来自一个第三方的类库), 那也没办法直接修改原始类, 给它重新定义一个接口. 在这种情况下, 该如何实现代理模式呢?
对于这种外部类的扩展, 一般都是采用继承的方式. 这里也不例外. 让代理类继承原始类, 然后扩展附加功能. 原理很简单, 直接看代码就能明白. 具体代码如下所示:
public class UserControllerProxy extends UserController {
private MetricsCollector metricsCollector;
public UserControllerProxy() {
this.metricsCollector = new MetricsCollector();
}
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
UserVo userVo = super.login(telephone, password);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return userVo;
}
public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
UserVo userVo = super.register(telephone, password);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return userVo;
}
}
// UserControllerProxy使用举例
UserController userController = new UserControllerProxy();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 2.动态代理的原理解析
不过, 刚刚的代码实现还是有点问题. 一方面需要在代理类中, 将原始类中的所有的方法, 都重新实现一遍, 并且为每个方法都附加相似的代码逻辑. 另一方面, 如果要添加的附加功能的类有不止一个, 需要针对每个类都创建一个代理类.
如果有 50 个要添加附加功能的原始类, 那就要创建 50 个对应的代理类. 这会导致项目中类的个数成倍增加, 增加了代码维护成本. 并且每个代理类中的代码都有点像模板式的 "重复" 代码, 也增加了不必要的开发成本. 这个问题怎么解决呢?
可以使用动态代理来解决这个问题. 所谓动态代理(Dynamic Proxy), 就是不事先为每个原始类编写代理类, 而是在运行的时候, 动态地创建原始类对应的代理类, 然后在系统中用代理类替换掉原始类. 那如何实现动态代理呢?
如果你熟悉的是 Java 语言, 实现动态代理就是件很简单的事情. 因为 Java 语言本身就已经提供了动态代理的语法(实际上, 动态代理底层依赖的就是 Java 的反射语法). 来看一下, 如何用 Java 的动态代理来实现刚刚的功能. 具体的代码如下所示. 其中, MetricsCollectorProxy 作为一个动态代理类, 动态地给每个需要收集接口请求信息的类创建代理类.
public class MetricsCollectorProxy {
private MetricsCollector metricsCollector;
public MetricsCollectorProxy() {
this.metricsCollector = new MetricsCollector();
}
public Object createProxy(Object proxiedObject) {
Class<?>[] interfaces = proxiedObject.getClass().getInterfaces();
DynamicProxyHandler handler = new DynamicProxyHandler(proxiedObject);
return Proxy.newProxyInstance(proxiedObject.getClass().getClassLoader(), interfaces, handler);
}
private class DynamicProxyHandler implements InvocationHandler {
private Object proxiedObject;
public DynamicProxyHandler(Object proxiedObject) {
this.proxiedObject = proxiedObject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long startTimestamp = System.currentTimeMillis();
Object result = method.invoke(proxiedObject, args);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
String apiName = proxiedObject.getClass().getName() + ":" + method.getName();
RequestInfo requestInfo = new RequestInfo(apiName, responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return result;
}
}
}
// MetricsCollectorProxy使用举例
MetricsCollectorProxy proxy = new MetricsCollectorProxy();
IUserController userController = (IUserController) proxy.createProxy(new UserController());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
实际上, Spring AOP 底层的实现原理就是基于动态代理. 用户配置好需要给哪些类创建代理, 并定义好在执行原始类的业务代码前后执行哪些附加功能. Spring 为这些类创建动态代理对象, 并在 JVM 中替代原始类对象. 原本在代码中执行的原始类的方法, 被换作执行代理类的方法, 也就实现了给原始类添加附加功能的目的.
# 3.代理模式的应用场景
代理模式的应用场景非常多, 这里列举一些比较常见的用法.
# (1)业务系统的非功能性需求开发
代理模式最常用的一个应用场景就是, 在业务系统中开发一些非功能性需求, 比如: 监控, 统计, 鉴权, 限流, 事务, 幂等, 日志. 将这些附加功能与业务功能解耦, 放到代理类中统一处理, 让程序员只需要关注业务方面的开发. 实际上, 前面举的搜集接口请求信息的例子, 就是这个应用场景的一个典型例子.
如果你熟悉 Java 语言和 Spring 开发框架, 这部分工作都是可以在 Spring AOP 切面中完成的. 前面也提到, Spring AOP 底层的实现原理就是基于动态代理.
# (2)代理模式在RPC,缓存中的应用
实际上, RPC 框架也可以看作一种代理模式, GoF 的《设计模式》一书中把它称作远程代理. 通过远程代理, 将网络通信, 数据编解码等细节隐藏起来. 客户端在使用 RPC 服务的时候, 就像使用本地函数一样, 无需了解跟服务器交互的细节. 除此之外, RPC 服务的开发者也只需要开发业务逻辑, 就像开发本地使用的函数一样, 不需要关注跟客户端的交互细节.
**再来看代理模式在缓存中的应用. ** 假设要开发一个接口请求的缓存功能, 对于某些接口请求, 如果入参相同, 在设定的过期时间内, 直接返回缓存结果, 而不用重新进行逻辑处理. 比如, 针对获取用户个人信息的需求, 可以开发两个接口, 一个支持缓存, 一个支持实时查询. 对于需要实时数据的需求, 让其调用实时查询接口, 对于不需要实时数据的需求, 让其调用支持缓存的接口. 那如何来实现接口请求的缓存功能呢?
最简单的实现方法就是刚刚讲到的, 给每个需要支持缓存的查询需求都开发两个不同的接口, 一个支持缓存, 一个支持实时查询. 但这样做显然增加了开发成本, 而且会让代码看起来非常臃肿(接口个数成倍增加), 也不方便缓存接口的集中管理(增加, 删除缓存接口), 集中配置(比如配置每个接口缓存过期时间).
针对这些问题, 代理模式就能派上用场了, 确切地说, 应该是动态代理. 如果是基于 Spring 框架来开发的话, 那就可以在 AOP 切面中完成接口缓存的功能. 在应用启动的时候, 从配置文件中加载需要支持缓存的接口, 以及相应的缓存策略(比如过期时间)等. 当请求到来的时候, 在 AOP 切面中拦截请求, 如果请求中带有支持缓存的字段(比如 http://...?..&cached=true), 便从缓存(内存缓存或者 Redis 缓存等)中获取数据直接返回.
重点回顾
1.代理模式的原理与实现
在不改变原始类(或叫被代理类)的情况下, 通过引入代理类来给原始类附加功能. 一般情况下, 让代理类和原始类实现同样的接口. 但是, 如果原始类并没有定义接口, 并且原始类代码并不是我们开发维护的. 在这种情况下, 可以通过让代理类继承原始类的方法来实现代理模式.
2.动态代理的原理与实现
静态代理需要针对每个类都创建一个代理类, 并且每个代理类中的代码都有点像模板式的 "重复" 代码, 增加了维护成本和开发成本. 对于静态代理存在的问题, 可以通过动态代理来解决. 不事先为每个原始类编写代理类, 而是在运行的时候动态地创建原始类对应的代理类, 然后在系统中用代理类替换掉原始类.
3.代理模式的应用场景
代理模式常用在业务系统中开发一些非功能性需求, 比如: 监控, 统计, 鉴权, 限流, 事务, 幂等, 日志. 将这些附加功能与业务功能解耦, 放到代理类统一处理, 让程序员只需要关注业务方面的开发. 除此之外, 代理模式还可以用在 RPC, 缓存等应用场景中.
# 49-桥接模式:如何实现支持不同类型和渠道的消息推送系统?
今天再学习另外一种结构型模式: 桥接模式. 桥接模式的代码实现非常简单, 但是理解起来稍微有点难度, 并且应用场景也比较局限, 所以相当于代理模式来说, 桥接模式在实际的项目中并没有那么常用, 只需要简单了解, 见到能认识就可以, 并不是学习的重点.
# 1.桥接模式的原理解析
桥接模式, 也叫作桥梁模式, 英文是 Bridge Design Pattern. 这个模式可以说是 23 种设计模式中最难理解的模式之一了. 我查阅了比较多的书籍和资料之后发现, 对于这个模式有两种不同的理解方式.
当然, 这其中 "最纯正" 的理解方式, 当属 GoF 的《设计模式》一书中对桥接模式的定义. 毕竟, 这 23 种经典的设计模式, 最初就是由这本书总结出来的. 在 GoF 的《设计模式》一书中, 桥接模式是这么定义的: "Decouple an abstraction from its implementation so that the two can vary independently." 翻译成中文就是: "将抽象和实现解耦, 让它们可以独立变化."
关于桥接模式, 很多书籍还有另外一种理解方式: "一个类存在两个(或多个)独立变化的维度, 通过组合的方式, 让这两个(或多个)维度可以独立进行扩展." 通过组合关系来替代继承关系, 避免继承层次的指数级爆炸. 这种理解方式非常类似于, 之前讲过的 "组合优于继承" 设计原则, 所以这里就不多解释了. 重点看下 GoF 的理解方式.
GoF 给出的定义非常的简短, 单凭这一句话, 估计没几个人能看懂是什么意思. 所以通过 JDBC 驱动的例子来解释一下. JDBC 驱动是桥接模式的经典应用. 先来看一下, 如何利用 JDBC 驱动来查询数据库. 具体的代码如下所示:
Class.forName("com.mysql.jdbc.Driver");// 加载及注册JDBC驱动程序
String url = "jdbc:mysql://localhost:3306/sample_db?user=root&password=your_password";
Connection con = DriverManager.getConnection(url);
Statement stmt = con.createStatement();
String query = "select * from test";
ResultSet rs=stmt.executeQuery(query);
while(rs.next()) {
rs.getString(1);
rs.getInt(2);
}
2
3
4
5
6
7
8
9
10
如果想要把 MySQL 数据库换成 Oracle 数据库, 只需要把第一行代码中的 com.mysql.jdbc.Driver 换成 oracle.jdbc.driver.OracleDriver 就可以了. 当然, 也有更灵活的实现方式, 可以把需要加载的 Driver 类写到配置文件中, 当程序启动的时候, 自动从配置文件中加载, 这样在切换数据库的时候, 都不需要修改代码, 只需要修改配置文件就可以了.
不管是改代码还是改配置, 在项目中, 从一个数据库切换到另一种数据库, 都只需要改动很少的代码, 或者完全不需要改动代码, 那如此优雅的数据库切换是如何实现的呢?
源码之下无秘密. 要弄清楚这个问题, 先从 com.mysql.jdbc.Driver 这个类的代码看起. 我摘抄了部分相关代码, 放到了这里, 可以看一下.
package com.mysql.jdbc;
import java.sql.SQLException;
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
/**
* Construct a new driver and register it with DriverManager
*
* @throws SQLException if a database error occurs.
*/
public Driver() throws SQLException {
// Required for Class.forName().newInstance()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
结合 com.mysql.jdbc.Driver 的代码实现可以发现, 当执行 Class.forName("com.mysql.jdbc.Driver") 这条语句的时候, 实际上是做了两件事情. 第一件事情是要求 JVM 查找并加载指定的 Driver 类, 第二件事情是执行该类的静态代码, 也就是将 MySQL Driver 注册到 DriverManager 类中.
现在再来看一下, DriverManager 类是干什么用的. 具体的代码如下所示. 当把具体的 Driver 实现类(比如, com.mysql.jdbc.Driver) 注册到 DriverManager 之后, 后续所有对 JDBC 接口的调用, 都会委派到对具体的 Driver 实现类来执行. 而 Driver 实现类都实现了相同的接口(java.sql.Driver ), 这也是可以灵活切换 Driver 的原因.
public class DriverManager {
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<DriverInfo>();
//...
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
//...
public static synchronized void registerDriver(java.sql.Driver driver) throws SQLException {
if (driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver));
} else {
throw new NullPointerException();
}
}
public static Connection getConnection(String url, String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
桥接模式的定义是 "将抽象和实现解耦, 让它们可以独立变化". 那弄懂定义中 "抽象" 和 "实现" 两个概念, 就是理解桥接模式的关键. 那在 JDBC 这个例子中, 什么是 "抽象"? 什么是 "实现" 呢?
实际上, JDBC 本身就相当于 "抽象" . 注意, 这里所说的 "抽象", 指的并非 "抽象类" 或 "接口", 而是跟具体的数据库无关的, 被抽象出来的一套 "类库". 具体的 Driver(比如, com.mysql.jdbc.Driver)就相当于 "实现". 注意, 这里所说的 "实现", 也并非指 "接口的实现类", 而是跟具体数据库相关的一套 "类库". JDBC 和 Driver 独立开发, 通过对象之间的组合关系, 组装在一起. JDBC 的所有逻辑操作, 最终都委托给 Driver 来执行.
我画了一张图帮助你理解, 可以结合着刚才的讲解一块看.

# 2.桥接模式的应用举例
前面讲过一个 API 接口监控告警的例子: 根据不同的告警规则, 触发不同类型的告警. 告警支持多种通知渠道, 包括: 邮件, 短信, 微信, 自动语音电话. 通知的紧急程度有多种类型, 包括: SEVERE(严重), URGENCY(紧急), NORMAL(普通), TRIVIAL(无关紧要). 不同的紧急程度对应不同的通知渠道. 比如, SERVE(严重)级别的消息会通过 "自动语音电话" 告知相关人员.
在当时的代码实现中, 关于发送告警信息那部分代码, 只给出了粗略的设计, 现在来一块实现一下. 先来看最简单, 最直接的一种实现方式. 代码如下所示:
public enum NotificationEmergencyLevel {
SEVERE, URGENCY, NORMAL, TRIVIAL
}
public class Notification {
private List<String> emailAddresses;
private List<String> telephones;
private List<String> wechatIds;
public Notification() {
}
public void setEmailAddress(List<String> emailAddress) {
this.emailAddresses = emailAddress;
}
public void setTelephones(List<String> telephones) {
this.telephones = telephones;
}
public void setWechatIds(List<String> wechatIds) {
this.wechatIds = wechatIds;
}
public void notify(NotificationEmergencyLevel level, String message) {
if (level.equals(NotificationEmergencyLevel.SEVERE)) {
//...自动语音电话
} else if (level.equals(NotificationEmergencyLevel.URGENCY)) {
//...发微信
} else if (level.equals(NotificationEmergencyLevel.NORMAL)) {
//...发邮件
} else if (level.equals(NotificationEmergencyLevel.TRIVIAL)) {
//...发邮件
}
}
}
// 在API监控告警的例子中, 如下来使用Notification类:
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Notification 类的代码实现有一个最明显的问题, 那就是有很多 if-else 分支逻辑. 实际上, 如果每个分支中的代码都不复杂, 后期也没有无限膨胀的可能(增加更多 if-else 分支判断), 那这样的设计问题并不大, 没必要非得一定要摒弃 if-else 分支逻辑.
不过, Notification 的代码显然不符合这个条件. 因为每个 if-else 分支中的代码逻辑都比较复杂, 发送通知的所有逻辑都扎堆在 Notification 类中. 类的代码越多, 就越难读懂, 越难修改, 维护的成本也就越高. 很多设计模式都是试图将庞大的类拆分成更细小的类, 然后再通过某种更合理的结构组装在一起.
针对 Notification 的代码, 将不同渠道的发送逻辑剥离出来, 形成独立的消息发送类(MsgSender 相关类). 其中, Notification 类相当于抽象, MsgSender 类相当于实现, 两者可以独立开发, 通过组合关系(也就是桥梁)任意组合在一起. 所谓任意组合的意思就是, 不同紧急程度的消息和发送渠道之间的对应关系, 不是在代码中固定写死的, 可以动态地去指定(比如, 通过读取配置来获取对应关系).
按照这个设计思路, 对代码进行重构. 重构之后的代码如下所示:
public interface MsgSender {
void send(String message);
}
public class TelephoneMsgSender implements MsgSender {
private List<String> telephones;
public TelephoneMsgSender(List<String> telephones) {
this.telephones = telephones;
}
@Override
public void send(String message) {
//...
}
}
public class EmailMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似, 所以省略...
}
public class WechatMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似, 所以省略...
}
public abstract class Notification {
protected MsgSender msgSender;
public Notification(MsgSender msgSender) {
this.msgSender = msgSender;
}
public abstract void notify(String message);
}
public class SevereNotification extends Notification {
public SevereNotification(MsgSender msgSender) {
super(msgSender);
}
@Override
public void notify(String message) {
msgSender.send(message);
}
}
public class UrgencyNotification extends Notification {
// 与SevereNotification代码结构类似, 所以省略...
}
public class NormalNotification extends Notification {
// 与SevereNotification代码结构类似, 所以省略...
}
public class TrivialNotification extends Notification {
// 与SevereNotification代码结构类似, 所以省略...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
重点回顾
总体上来讲, 桥接模式的原理比较难理解, 但代码实现相对简单.
对于这个模式有两种不同的理解方式. 在 GoF 的《设计模式》一书中, 桥接模式被定义为: "将抽象和实现解耦, 让它们可以独立变化." 在其他资料和书籍中, 还有另外一种更加简单的理解方式: "一个类存在两个(或多个)独立变化的维度, 通过组合的方式, 让这两个(或多个)维度可以独立进行扩展."
对于第一种 GoF 的理解方式, 弄懂定义中 "抽象" 和 "实现" 两个概念, 是理解它的关键. 定义中的 "抽象", 指的并非 "抽象类" 或 "接口", 而是被抽象出来的一套 "类库", 它只包含骨架代码, 真正的业务逻辑需要委派给定义中的 "实现 "来完成. 而定义中的 "实现", 也并非 "接口的实现类", 而是的一套独立的 "类库". "抽象" 和 "实现" 独立开发, 通过对象之间的组合关系, 组装在一起.
对于第二种理解方式, 它非常类似之前讲过的 "组合优于继承" 设计原则, 通过组合关系来替代继承关系, 避免继承层次的指数级爆炸.
# 50-装饰器模式:通过剖析Java IO类库源码学习装饰器模式
本节通过剖析 Java IO 类的设计思想, 再学习一种新的结构型模式, 装饰器模式. 它的代码结构跟桥接模式非常相似, 不过要解决的问题却大不相同.
# 1.Java IO类的"奇怪"用法
Java IO 类库非常庞大和复杂, 有几十个类, 负责 IO 数据的读取和写入. 如果对 Java IO 类做一下分类, 可以从下面两个维度将它划分为四类. 具体如下所示:

针对不同的读取和写入场景, Java IO 又在这四个父类基础之上, 扩展出了很多子类. 具体如下所示:

在我初学 Java 的时候, 曾经对 Java IO 的一些用法产生过很大疑惑, 比如下面这样一段代码. 打开文件 test.txt, 从中读取数据. 其中, InputStream 是一个抽象类, FileInputStream 是专门用来读取文件流的子类. BufferedInputStream 是一个支持带缓存功能的数据读取类, 可以提高数据读取的效率.
InputStream in = new FileInputStream("/user/nano/test.txt");
InputStream bin = new BufferedInputStream(in);
byte[] data = new byte[128];
while (bin.read(data) != -1) {
//...
}
2
3
4
5
6
初看上面的代码, 会觉得 Java IO 的用法比较麻烦, 需要先创建一个 FileInputStream 对象, 然后再传递给 BufferedInputStream 对象来使用. Java IO 为什么不设计一个继承 FileInputStream 并且支持缓存的 BufferedFileInputStream 类呢? 这样就可以像下面的代码中这样, 直接创建一个 BufferedFileInputStream 类对象, 打开文件读取数据, 用起来岂不是更加简单?
InputStream bin = new BufferedFileInputStream("/user/nano/test.txt");
byte[] data = new byte[128];
while (bin.read(data) != -1) {
//...
}
2
3
4
5
# 2.基于继承的设计方案
如果 InputStream 只有一个子类 FileInputStream 的话, 那在 FileInputStream 基础之上, 再设计一个孙子类 BufferedFileInputStream, 也算是可以接受的, 毕竟继承结构还算简单. 但实际上, 继承 InputStream 的子类有很多. 需要给每一个 InputStream 的子类, 再继续派生支持缓存读取的子类.
除了支持缓存读取之外, 如果还需要对功能进行其他方面的增强, 比如下面的 DataInputStream 类, 支持按照基本数据类型(int, boolean, long 等)来读取数据.
FileInputStream in = new FileInputStream("/user/wangzheng/test.txt");
DataInputStream din = new DataInputStream(in);
int data = din.readInt();
2
3
在这种情况下, 如果继续按照继承的方式来实现的话, 就需要再继续派生出 DataFileInputStream, DataPipedInputStream 等类. 如果还需要既支持缓存, 又支持按照基本类型读取数据的类, 那就要再继续派生出 BufferedDataFileInputStream, BufferedDataPipedInputStream 等 n 多类. 这还只是附加了两个增强功能, 如果需要附加更多的增强功能, 那就会导致组合爆炸, 类继承结构变得无比复杂, 代码既不好扩展, 也不好维护. 这也是前面讲的不推荐使用继承的原因.
# 3.基于装饰器模式的设计方案
前面讲到 "组合优于继承", 可以 "使用组合来替代继承". 针对刚刚的继承结构过于复杂的问题, 可以通过将继承关系改为组合关系来解决. 下面的代码展示了 Java IO 的这种设计思路. 不过, 我对代码做了简化, 只抽象出了必要的代码结构, 感兴趣的话可以去查看 JDK 源码.
public abstract class InputStream {
// ...
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
public int read(byte b[], int off, int len) throws IOException {
// ...
}
public long skip(long n) throws IOException {
// ...
}
public int available() throws IOException {
return 0;
}
public void close() throws IOException {}
public synchronized void mark(int readlimit) {}
public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
public boolean markSupported() {
return false;
}
}
public class BufferedInputStream extends InputStream {
protected volatile InputStream in;
protected BufferedInputStream(InputStream in) {
this.in = in;
}
// ...实现基于缓存的读数据接口
}
public class DataInputStream extends InputStream {
protected volatile InputStream in;
protected DataInputStream(InputStream in) {
this.in = in;
}
// ...实现读取基本类型数据的接口
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
看了上面的代码, 你可能会问, 那装饰器模式就是简单的 "用组合替代继承" 吗? 当然不是. 从 Java IO 的设计来看, 装饰器模式相对于简单的组合关系, 还有两个比较特殊的地方.
第一个比较特殊的地方是: 装饰器类和原始类继承同样的父类, 这样可以对原始类 "嵌套" 多个装饰器类 . 比如, 下面这样一段代码, 对 FileInputStream 嵌套了两个装饰器类: BufferedInputStream 和 DataInputStream, 让它既支持缓存读取, 又支持按照基本数据类型来读取数据.
InputStream in = new FileInputStream("/user/wangzheng/test.txt");
InputStream bin = new BufferedInputStream(in);
DataInputStream din = new DataInputStream(bin);
int data = din.readInt();
2
3
4
第二个比较特殊的地方是: 装饰器类是对功能的增强, 这也是装饰器模式应用场景的一个重要特点 . 实际上, 符合 "组合关系" 这种代码结构的设计模式有很多, 比如之前讲过的代理模式, 桥接模式, 还有现在的装饰器模式. 尽管它们的代码结构很相似, 但是每种设计模式的意图是不同的. 就拿比较相似的代理模式和装饰器模式来说吧, 代理模式中, 代理类附加的是跟原始类无关的功能, 而在装饰器模式中, 装饰器类附加的是跟原始类相关的增强功能.
// 代理模式的代码结构(下面的接口也可以替换成抽象类)
public interface IA {
void f();
}
public class A impelements IA {
public void f() { //...
}
}
public class AProxy impements IA {
private IA a;
public AProxy(IA a) {
this.a = a;
}
public void f() {
// 新添加的代理逻辑
a.f();
// 新添加的代理逻辑
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 装饰器模式的代码结构(下面的接口也可以替换成抽象类)
public interface IA {
void f();
}
public class A impelements IA {
public void f() { // ...
}
}
public class ADecorator impements IA {
private IA a;
public ADecorator(IA a) {
this.a = a;
}
public void f() {
// 功能增强代码
a.f();
// 功能增强代码
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
实际上, 如果去查看 JDK 的源码, 你会发现, BufferedInputStream, DataInputStream 并非继承自 InputStream, 而是另外一个叫 FilterInputStream 的类. 那这又是出于什么样的设计意图, 才引入这样一个类呢?
再重新来看一下 BufferedInputStream 类的代码. InputStream 是一个抽象类而非接口, 而且它的大部分函数(比如 read(), available())都有默认实现, 按理来说, 只需要在 BufferedInputStream 类中重新实现那些需要增加缓存功能的函数就可以了, 其他函数继承 InputStream 的默认实现. 但实际上, 这样做是行不通的.
对于即便是不需要增加缓存功能的函数来说, BufferedInputStream 还是必须把它重新实现一遍, 简单包裹对 InputStream 对象的函数调用. 具体的代码示例如下所示. 如果不重新实现, 那 BufferedInputStream 类就无法将最终读取数据的任务, 委托给传递进来的 InputStream 对象来完成. 这一部分稍微有点不好理解, 要多思考一下.
public class BufferedInputStream extends InputStream {
protected volatile InputStream in;
protected BufferedInputStream(InputStream in) {
this.in = in;
}
// f()函数不需要增强, 只是重新调用一下InputStream in对象的f()
public void f() {
in.f();
}
}
2
3
4
5
6
7
8
9
10
11
12
实际上, DataInputStream 也存在跟 BufferedInputStream 同样的问题. 为了避免代码重复, Java IO 抽象出了一个装饰器父类 FilterInputStream, 代码实现如下所示. InputStream 的所有的装饰器类(BufferedInputStream, DataInputStream)都继承自这个装饰器父类. 这样, 装饰器类只需要实现它需要增强的方法就可以了, 其他方法继承装饰器父类的默认实现.
public class FilterInputStream extends InputStream {
protected volatile InputStream in;
protected FilterInputStream(InputStream in) {
this.in = in;
}
public int read() throws IOException {
return in.read();
}
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
public int read(byte b[], int off, int len) throws IOException {
return in.read(b, off, len);
}
public long skip(long n) throws IOException {
return in.skip(n);
}
public int available() throws IOException {
return in.available();
}
public void close() throws IOException {
in.close();
}
public synchronized void mark(int readlimit) {
in.mark(readlimit);
}
public synchronized void reset() throws IOException {
in.reset();
}
public boolean markSupported() {
return in.markSupported();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
重点回顾
装饰器模式主要解决继承关系过于复杂的问题, 通过组合来替代继承. 它主要的作用是给原始类添加增强功能. 这也是判断是否该用装饰器模式的一个重要的依据. 除此之外, 装饰器模式还有一个特点, 那就是可以对原始类嵌套使用多个装饰器. 为了满足这个应用场景, 在设计的时候, 装饰器类需要跟原始类继承相同的抽象类或者接口.
# 51-适配器模式:代理,适配器,桥接,装饰,这四个模式有何区别?
今天再来学习一个比较常用的结构型模式: 适配器模式. 这个模式相对来说还是比较简单, 好理解的, 应用场景也很具体, 总体上来讲比较好掌握.
关于适配器模式, 今天主要学习它的两种实现方式, 类适配器和对象适配器, 以及 5 种常见的应用场景. 同时还会通过剖析 slf4j 日志框架, 来给展示这个模式在真实项目中的应用. 除此之外, 在文章的最后, 还对代理, 桥接, 装饰器, 适配器, 这 4 种代码结构非常相似的设计模式做简单的对比, 对这几节内容做一个简单的总结.
# 1.适配器模式的原理与实现
适配器模式的英文翻译是 Adapter Design Pattern. 顾名思义, 这个模式就是用来做适配的, 它将不兼容的接口转换为可兼容的接口, 让原本由于接口不兼容而不能一起工作的类可以一起工作. 对于这个模式, 有一个经常被拿来解释它的例子, 就是 USB 转接头充当适配器, 把两种不兼容的接口, 通过转接变得可以一起工作.
原理很简单, 再来看下它的代码实现. 适配器模式有两种实现方式: 类适配器和对象适配器. 其中, 类适配器使用继承关系来实现, 对象适配器使用组合关系来实现. 具体的代码实现如下所示. 其中, ITarget 表示要转化成的接口定义. Adaptee 是一组不兼容 ITarget 接口定义的接口, Adaptor 将 Adaptee 转化成一组符合 ITarget 接口定义的接口.
// 类适配器: 基于继承
public interface ITarget {
void f1();
void f2();
void fc();
}
public class Adaptee {
public void fa() { //...
}
public void fb() { //...
}
public void fc() { //...
}
}
public class Adaptor extends Adaptee implements ITarget {
public void f1() {
super.fa();
}
public void f2() {
//...重新实现f2()...
}
// 这里fc()不需要实现, 直接继承自Adaptee, 这是跟对象适配器最大的不同点
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 对象适配器: 基于组合
public interface ITarget {
void f1();
void f2();
void fc();
}
public class Adaptee {
public void fa() { //...
}
public void fb() { //...
}
public void fc() { //...
}
}
public class Adaptor implements ITarget {
private Adaptee adaptee;
public Adaptor(Adaptee adaptee) {
this.adaptee = adaptee;
}
public void f1() {
adaptee.fa(); // 委托给Adaptee
}
public void f2() {
// ...重新实现f2()...
}
public void fc() {
adaptee.fc();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
针对这两种实现方式, 在实际的开发中, 到底该如何选择使用哪一种呢? 判断的标准主要有两个, 一个是 Adaptee 接口的个数, 另一个是 Adaptee 和 ITarget 的契合程度.
- 如果 Adaptee 接口并不多, 那两种实现方式都可以.
- 如果 Adaptee 接口很多, 而且 Adaptee 和 ITarget 接口定义大部分都相同, 那推荐使用类适配器, 因为 Adaptor 复用父类 Adaptee 的接口, 比起对象适配器的实现方式, Adaptor 的代码量要少一些.
- 如果 Adaptee 接口很多, 而且 Adaptee 和 ITarget 接口定义大部分都不相同, 那推荐使用对象适配器, 因为组合结构相对于继承更加灵活.
# 2.适配器模式应用场景总结
原理和实现讲完了, 都不复杂. 再来看, 到底什么时候会用到适配器模式呢?
一般来说, 适配器模式可以看作一种 "补偿模式", 用来补救设计上的缺陷. 应用这种模式算是 "无奈之举" . 如果在设计初期就能协调规避接口不兼容的问题, 那这种模式就没有应用的机会了.
前面反复提到, 适配器模式的应用场景是 "接口不兼容". 那在实际的开发中, 什么情况下才会出现接口不兼容呢?
# (1)封装有缺陷的接口设计
假设依赖的外部系统在接口设计方面有缺陷(比如包含大量静态方法), 引入之后会影响到自身代码的可测试性. 为了隔离设计上的缺陷, 希望对外部系统提供的接口进行二次封装, 抽象出更好的接口设计, 这个时候就可以使用适配器模式了.
举个例子来解释一下:
public class CD { // 这个类来自外部sdk, 所以无权修改它的代码
//...
public static void staticFunction1() { //...
}
public void uglyNamingFunction2() { //...
}
public void tooManyParamsFunction3(int paramA, int paramB, ...) { //...
}
public void lowPerformanceFunction4() { //...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 使用适配器模式进行重构
public class ITarget {
void function1();
void function2();
void fucntion3(ParamsWrapperDefinition paramsWrapper);
void function4();
// ...
}
// 注意: 适配器类的命名不一定非得末尾带Adaptor
public class CDAdaptor extends CD implements ITarget {
// ...
public void function1() {
super.staticFunction1();
}
public void function2() {
super.uglyNamingFucntion2();
}
public void function3(ParamsWrapperDefinition paramsWrapper) {
super.tooManyParamsFunction3(paramsWrapper.getParamA(), ...);
}
public void function4() {
// ...reimplement it...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# (2)统一多个类的接口设计
某个功能的实现依赖多个外部系统(或者说类) . 通过适配器模式, 将它们的接口适配为统一的接口定义, 然后就可以使用多态的特性来复用代码逻辑. 还是举个例子来解释一下.
假设系统要对用户输入的文本内容做敏感词过滤, 为了提高过滤的召回率, 引入了多款第三方敏感词过滤系统, 依次对用户输入的内容进行过滤, 过滤掉尽可能多的敏感词. 但每个系统提供的过滤接口都是不同的. 这就意味着没法复用一套逻辑来调用各个系统. 这个时候, 就可以使用适配器模式, 将所有系统的接口适配为统一的接口定义, 这样可以复用调用敏感词过滤的代码.
public class ASensitiveWordsFilter { // A敏感词过滤系统提供的接口
// text是原始文本, 函数输出用***替换敏感词之后的文本
public String filterSexyWords(String text) {
// ...
}
public String filterPoliticalWords(String text) {
// ...
}
}
2
3
4
5
6
7
8
9
10
public class BSensitiveWordsFilter { // B敏感词过滤系统提供的接口
public String filter(String text) {
//...
}
}
2
3
4
5
public class CSensitiveWordsFilter { // C敏感词过滤系统提供的接口
public String filter(String text, String mask) {
//...
}
}
2
3
4
5
// 未使用适配器模式之前的代码: 代码的可测试性, 扩展性不好
public class RiskManagement {
private ASensitiveWordsFilter aFilter = new ASensitiveWordsFilter();
private BSensitiveWordsFilter bFilter = new BSensitiveWordsFilter();
private CSensitiveWordsFilter cFilter = new CSensitiveWordsFilter();
public String filterSensitiveWords(String text) {
String maskedText = aFilter.filterSexyWords(text);
maskedText = aFilter.filterPoliticalWords(maskedText);
maskedText = bFilter.filter(maskedText);
maskedText = cFilter.filter(maskedText, "***");
return maskedText;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用适配器模式进行改造
public interface ISensitiveWordsFilter { // 统一接口定义
String filter(String text);
}
public class ASensitiveWordsFilterAdaptor implements ISensitiveWordsFilter {
private ASensitiveWordsFilter aFilter;
public String filter(String text) {
String maskedText = aFilter.filterSexyWords(text);
maskedText = aFilter.filterPoliticalWords(maskedText);
return maskedText;
}
}
// ...省略BSensitiveWordsFilterAdaptor, CSensitiveWordsFilterAdaptor...
// 扩展性更好, 更加符合开闭原则, 如果添加一个新的敏感词过滤系统,
// 这个类完全不需要改动; 而且基于接口而非实现编程, 代码的可测试性更好.
public class RiskManagement {
private List<ISensitiveWordsFilter> filters = new ArrayList<>();
public void addSensitiveWordsFilter(ISensitiveWordsFilter filter) {
filters.add(filter);
}
public String filterSensitiveWords(String text) {
String maskedText = text;
for (ISensitiveWordsFilter filter : filters) {
maskedText = filter.filter(maskedText);
}
return maskedText;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# (3)替换依赖的外部系统
当把项目中依赖的一个外部系统替换为另一个外部系统的时候, 利用适配器模式, 可以减少对代码的改动. 具体的代码示例如下所示:
// 外部系统A
public interface IA {
// ...
void fa();
}
public class A implements IA {
// ...
public void fa() { // ...
}
}
// 在自己的项目中, 外部系统A的使用示例
public class Demo {
private IA a;
public Demo(IA a) {
this.a = a;
}
//...
}
Demo d = new Demo(new A());
// 将外部系统A替换成外部系统B
public class BAdaptor implemnts IA {
private B b;
public BAdaptor(B b) {
this.b= b;
}
public void fa() {
//...
b.fb();
}
}
// 借助BAdaptor, Demo的代码中, 调用IA接口的地方都无需改动,
// 只需要将BAdaptor如下注入到Demo即可.
Demo d = new Demo(new BAdaptor(new B()));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# (4)兼容老版本接口
在做版本升级的时候, 对于一些要废弃的接口, 不直接将其删除, 而是暂时保留, 并且标注为 deprecated, 并将内部实现逻辑委托为新的接口实现. 这样做的好处是, 让使用它的项目有个过渡期, 而不是强制进行代码修改. 这也可以粗略地看作适配器模式的一个应用场景. 同样还是通过一个例子, 来进一步解释一下.
JDK1.0 中包含一个遍历集合容器的类 Enumeration. JDK2.0 对这个类进行了重构, 将它改名为 Iterator 类, 并且对它的代码实现做了优化. 但是考虑到如果将 Enumeration 直接从 JDK2.0 中删除, 那使用 JDK1.0 的项目如果切换到 JDK2.0, 代码就会编译不通过. 为了避免这种情况的发生, 必须把项目中所有使用到 Enumeration 的地方, 都修改为使用 Iterator 才行.
单独一个项目做 Enumeration 到 Iterator 的替换, 勉强还能接受. 但使用 Java 开发的项目太多了, 一次 JDK 的升级, 导致所有的项目不做代码修改就会编译报错, 这显然是不合理的. 这就是经常所说的不兼容升级. 为了做到兼容使用低版本 JDK 的老代码, 可以暂时保留 Enumeration 类, 并将其实现替换为直接调用 Itertor. 代码示例如下所示:
public class Collections {
// 修改实现
public static Emueration emumeration(final Collection c) {
return new Enumeration() {
Iterator i = c.iterator();
public boolean hasMoreElments() {
return i.hashNext();
}
public Object nextElement() {
return i.next():
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# (5)适配不同格式的数据
前面讲到, 适配器模式主要用于接口的适配, 实际上, 它还可以用在不同格式的数据之间的适配. 比如, 把从不同征信系统拉取的不同格式的征信数据, 统一为相同的格式, 以方便存储和使用. 再比如, Java 中的 Arrays.asList() 也可以看作一种数据适配器, 将数组类型的数据转化为集合容器类型.
List<String> stooges = Arrays.asList("Larry", "Moe", "Curly");
# 3.剖析适配器模式在Java日志中的应用
Java 中有很多日志框架. 其中, 比较常用的有 log4j, logback, 以及 JDK 提供的 JUL(java.util.logging) 和 Apache 的 JCL(Jakarta Commons Logging) 等.
大部分日志框架都提供了相似的功能, 比如按照不同级别(debug, info, warn, erro)打印日志等, 但它们却并没有实现统一的接口. 这主要可能是历史的原因, 它不像 JDBC 那样, 一开始就制定了数据库操作的接口规范.
如果只是开发一个自己用的项目, 那用什么日志框架都可以, log4j, logback 随便选一个就好. 但如果开发的是一个集成到其他系统的组件, 框架, 类库等, 那日志框架的选择就没那么随意了.
比如, 项目中用到的某个组件使用 log4j 来打印日志, 而项目本身使用的是 logback. 将组件引入到项目之后, 项目就相当于有了两套日志打印框架. 每种日志框架都有自己特有的配置方式. 所以要针对每种日志框架编写不同的配置文件(比如, 日志存储的文件地址, 打印日志的格式). 如果引入多个组件, 每个组件使用的日志框架都不一样, 那日志本身的管理工作就变得非常复杂. 所以, 为了解决这个问题, 需要统一日志打印框架.
如果你是做 Java 开发的, 那 Slf4j 这个日志框架你肯定不陌生, 它相当于 JDBC 规范, 提供了一套打印日志的统一接口规范. 不过, 它只定义了接口, 并没有提供具体的实现, 需要配合其他日志框架(log4j, logback...)来使用.
不仅如此, Slf4j 的出现晚于 JUL, JCL, log4j 等日志框架, 所以这些日志框架也不可能牺牲掉版本兼容性, 将接口改造成符合 Slf4j 接口规范. Slf4j 也事先考虑到了这个问题, 所以它不仅仅提供了统一的接口定义, 还提供了针对不同日志框架的适配器. 对不同日志框架的接口进行二次封装, 适配成统一的 Slf4j 接口定义. 具体的代码示例如下所示:
// slf4j统一的接口定义
package org.slf4j;
public interface Logger {
public boolean isTraceEnabled();
public void trace(String msg);
public void trace(String format, Object arg);
public void trace(String format, Object arg1, Object arg2);
public void trace(String format, Object[] argArray);
public void trace(String msg, Throwable t);
public boolean isDebugEnabled();
public void debug(String msg);
public void debug(String format, Object arg);
public void debug(String format, Object arg1, Object arg2)
public void debug(String format, Object[] argArray)
public void debug(String msg, Throwable t);
//...省略info, warn, error等一堆接口
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// log4j日志框架的适配器
// Log4jLoggerAdapter实现了LocationAwareLogger接口,
// 其中LocationAwareLogger继承自Logger接口,
// 也就相当于Log4jLoggerAdapter实现了Logger接口.
package org.slf4j.impl;
public final class Log4jLoggerAdapter extends MarkerIgnoringBase
implements LocationAwareLogger, Serializable {
final transient org.apache.log4j.Logger logger; // log4j
public boolean isDebugEnabled() {
return logger.isDebugEnabled();
}
public void debug(String msg) {
logger.log(FQCN, Level.DEBUG, msg, null);
}
public void debug(String format, Object arg) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.format(format, arg);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}
public void debug(String format, Object arg1, Object arg2) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.format(format, arg1, arg2);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}
public void debug(String format, Object[] argArray) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.arrayFormat(format, argArray);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}
public void debug(String msg, Throwable t) {
logger.log(FQCN, Level.DEBUG, msg, t);
}
//...省略一堆接口的实现...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
所以, 在开发业务系统或者开发框架, 组件的时候, 统一使用 Slf4j 提供的接口来编写打印日志的代码, 具体使用哪种日志框架实现(log4j, logback...), 是可以动态地指定的(使用 Java 的 SPI 技术), 只需要将相应的 SDK 导入到项目中即可.
不过, 你可能会说, 如果一些老的项目没有使用 Slf4j, 而是直接使用比如 JCL 来打印日志, 那如果想要替换成其他日志框架, 比如 log4j, 该怎么办呢? 实际上, Slf4j 不仅仅提供了从其他日志框架到 Slf4j 的适配器, 还提供了反向适配器, 也就是从 Slf4j 到其他日志框架的适配. 可以先将 JCL 切换为 Slf4j, 然后再将 Slf4j 切换为 log4j. 经过两次适配器的转换, 能就成功将 log4j 切换为了 logback.
# 4.代理,桥接,装饰器,适配器4种设计模式的区别
代理, 桥接, 装饰器, 适配器, 这 4 种模式是比较常用的结构型设计模式. 它们的代码结构非常相似. 笼统来说, 它们都可以称为 Wrapper 模式, 也就是通过 Wrapper 类二次封装原始类.
尽管代码结构相似, 但这 4 种设计模式的用意完全不同, 也就是说要解决的问题, 应用场景不同, 这也是它们的主要区别. 这里就简单说一下它们之间的区别.
代理模式: 代理模式在不改变原始类接口的条件下, 为原始类定义一个代理类, 主要目的是控制访问, 而非加强功能, 这是它跟装饰器模式最大的不同.
桥接模式: 桥接模式的目的是将接口部分和实现部分分离, 从而让它们可以较为容易, 也相对独立地加以改变.
装饰器模式: 装饰者模式在不改变原始类接口的情况下, 对原始类功能进行增强, 并且支持多个装饰器的嵌套使用.
适配器模式: 适配器模式是一种事后的补救策略. 适配器提供跟原始类不同的接口, 而代理模式, 装饰器模式提供的都是跟原始类相同的接口.
重点回顾
适配器模式是用来做适配, 它将不兼容的接口转换为可兼容的接口, 让原本由于接口不兼容而不能一起工作的类可以一起工作. 适配器模式有两种实现方式: 类适配器和对象适配器. 其中, 类适配器使用继承关系来实现, 对象适配器使用组合关系来实现.
一般来说, 适配器模式可以看作一种 "补偿模式", 用来补救设计上的缺陷. 应用这种模式算是 "无奈之举", 如果在设计初期就能协调规避接口不兼容的问题, 那这种模式就没有应用的机会了.
那在实际的开发中, 什么情况下才会出现接口不兼容呢? 一般有下面这样几种场景:
- 封装有缺陷的接口设计
- 统一多个类的接口设计
- 替换依赖的外部系统
- 兼容老版本接口
- 适配不同格式的数据
# 52-门面模式:如何设计合理的接口粒度以兼顾接口的易用性和通用性?
今天再来学习一种新的结构型模式: 门面模式. 门面模式原理和实现都特别简单, 应用场景也比较明确, 主要在接口设计方面使用.
如果你平时的工作涉及接口开发, 不知道你有没有遇到关于接口粒度的问题呢?
为了保证接口的可复用性(或者叫通用性), 需要将接口尽量设计得细粒度一点, 职责单一一点. 但如果接口的粒度过小, 在接口的使用者开发一个业务功能时, 就会导致需要调用 n 多细粒度的接口才能完成. 调用者肯定会抱怨接口不好用.
相反, 如果接口粒度设计得太大, 一个接口返回 n 多数据, 要做 n 多事情, 就会导致接口不够通用, 可复用性不好. 接口不可复用, 那针对不同的调用者的业务需求, 就需要开发不同的接口来满足, 这就会导致系统的接口无限膨胀.
那如何来解决接口的可复用性(通用性)和易用性之间的矛盾呢? 通过今天对于门面模式的学习, 我想你心中会有答案.
# 1.门面模式的原理与实现
门面模式, 也叫外观模式, 英文全称是 Facade Design Pattern. 在 GoF 的《设计模式》一书中, 门面模式是这样定义的:
Provide a unified interface to a set of interfaces in a subsystem. Facade Pattern defines a higher-level interface that makes the subsystem easier to use.
翻译成中文就是: 门面模式为子系统提供一组统一的接口, 定义一组高层接口让子系统更易用.
这个定义很简洁, 再进一步解释一下.
假设有一个系统 A, 提供了 a, b, c, d 四个接口. 系统 B 完成某个业务功能, 需要调用 A 系统的 a, b, d 接口. 利用门面模式, 提供一个包裹 a, b, d 接口调用的门面接口 x, 给系统 B 直接使用.
不知道你会不会有这样的疑问, 让系统 B 直接调用 a, b, d 感觉没有太大问题呀, 为什么还要提供一个包裹 a, b, d 的接口 x 呢? 还是通过一个具体的例子来解释一下.
假设刚刚提到的系统 A 是一个后端服务器, 系统 B 是 App 客户端. App 客户端通过后端服务器提供的接口来获取数据. App 和服务器之间是通过移动网络通信的, 网络通信耗时比较多, 为了提高 App 的响应速度, 要尽量减少 App 与服务器之间的网络通信次数.
假设, 完成某个业务功能(比如显示某个页面信息)需要 "依次" 调用 a, b, d 三个接口, 因自身业务的特点, 不支持并发调用这三个接口.
如果现在发现 App 客户端的响应速度比较慢, 排查之后发现, 是因为过多的接口调用过多的网络通信. 针对这种情况, 就可以利用门面模式, 让后端服务器提供一个包裹 a, b, d 三个接口调用的接口 x. App 客户端调用一次接口 x, 来获取到所有想要的数据, 将网络通信的次数从 3 次减少到 1 次, 也就提高了 App 的响应速度.
这里举的例子只是应用门面模式的其中一个意图, 也就是解决性能问题. 实际上, 不同的应用场景下, 使用门面模式的意图也不同. 接下来就来看一下门面模式的各种应用场景.
# 2.门面模式的应用场景举例
在 GoF 给出的定义中提到, "门面模式让子系统更加易用", 实际上, 它除了解决易用性问题之外, 还能解决其他很多方面的问题. 关于这一点, 我总结罗列了 3 个常用的应用场景.
除此之外, 还要强调一下, 门面模式定义中的 "子系统(subsystem)" 也可以有多种理解方式. 它既可以是一个完整的系统, 也可以是更细粒度的类或者模块. 关于这一点, 在下面的讲解中也会有体现.
# (1)解决易用性问题
门面模式可以用来封装系统的底层实现, 隐藏系统的复杂性, 提供一组更加简单易用, 更高层的接口. 比如, Linux 系统调用函数就可以看作一种 "门面". 它是 Linux 操作系统暴露给开发者的一组 "特殊" 的编程接口, 它封装了底层更基础的 Linux 内核调用. 再比如, Linux 的 Shell 命令, 实际上也可以看作一种门面模式的应用. 它继续封装系统调用, 提供更加友好, 简单的命令, 就可以直接通过执行命令来跟操作系统交互.
前面也多次讲过, 设计原则, 思想, 模式很多都是相通的, 是同一个道理不同角度的表述. 实际上, 从隐藏实现复杂性, 提供更易用接口这个意图来看, 门面模式有点类似之前讲到的迪米特法则(最少知识原则)和接口隔离原则: 两个有交互的系统, 只暴露有限的必要的接口. 除此之外, 门面模式还有点类似之前提到封装, 抽象的设计思想, 提供更抽象的接口, 封装底层实现细节.
# (2)解决性能问题
关于利用门面模式解决性能问题这一点, 刚刚已经讲过了. 通过将多个接口调用替换为一个门面接口调用, 减少网络通信成本, 提高 App 客户端的响应速度. 下面来讨论一下这样一个问题: 从代码实现的角度来看, 该如何组织门面接口和非门面接口?
如果门面接口不多, 完全可以将它跟非门面接口放到一块, 也不需要特殊标记, 当作普通接口来用即可. 如果门面接口很多, 可以在已有的接口之上, 再重新抽象出一层, 专门放置门面接口, 从类, 包的命名上跟原来的接口层做区分. 如果门面接口特别多, 并且很多都是跨多个子系统的, 可以将门面接口放到一个新的子系统中.
# (3)解决分布式事务问题
关于利用门面模式来解决分布式事务问题, 通过一个例子来解释一下.
在一个金融系统中, 有两个业务领域模型, 用户和钱包. 这两个业务领域模型都对外暴露了一系列接口, 比如用户的增删改查接口, 钱包的增删改查接口. 假设有这样一个业务场景: 在用户注册的时候, 不仅会创建用户(在数据库 User 表中), 还会给用户创建一个钱包(在数据库的 Wallet 表中).
对于这样一个简单的业务需求, 可以通过依次调用用户的创建接口和钱包的创建接口来完成. 但用户注册需要支持事务, 也就是说, 创建用户和钱包的两个操作, 要么都成功, 要么都失败, 不能一个成功, 一个失败.
要支持两个接口调用在一个事务中执行, 是比较难实现的, 这涉及分布式事务问题. 虽然可以通过引入分布式事务框架或者事后补偿的机制来解决, 但代码实现都比较复杂. 而最简单的解决方案是, 利用数据库事务或者 Spring 框架提供的事务(如果是 Java 语言的话), 在一个事务中, 执行创建用户和创建钱包这两个 SQL 操作. 这就要求两个 SQL 操作要在一个接口中完成, 所以可以借鉴门面模式的思想, 再设计一个包裹这两个操作的新接口, 让新接口在一个事务中执行两个 SQL 操作.
重点回顾
接口设计的好坏, 直接影响到类, 模块, 系统是否好用. 所以要多花点心思在接口设计上. 完成接口设计, 就相当于完成了一半的开发任务. 只要接口设计得好, 那代码就差不到哪里去.
接口粒度设计得太大, 太小都不好. 太大会导致接口不可复用, 太小会导致接口不易用. 在实际的开发中, 接口的可复用性和易用性需要 "微妙" 的权衡. 针对这个问题的一个基本的处理原则是, 尽量保持接口的可复用性, 但针对特殊情况, 允许提供冗余的门面接口, 来提供更易用的接口.
门面模式除了解决接口易用性问题之外, 今天还讲到了其他 2 个应用场景, 用它来解决性能问题和分布式事务问题.
# 53-组合模式:如何设计实现支持递归遍历的文件系统目录树结构?
组合模式跟之前讲的面向对象设计中的 "组合关系(通过组合来组装两个类)", 完全是两码事. 这里讲的 "组合模式", 主要是用来处理树形结构数据. 这里的 "数据", 可以简单理解为一组对象集合, 待会会详细讲解.
正因为其应用场景的特殊性, 数据必须能表示成树形结构, 这也导致了这种模式在实际的项目开发中并不那么常用. 但是, 一旦数据满足树形结构, 应用这种模式就能发挥很大的作用, 能让代码变得非常简洁.
# 1.组合模式的原理与实现
在 GoF 的《设计模式》一书中, 组合模式是这样定义的:
Compose objects into tree structure to represent part-whole hierarchies.Composite lets client treat individual objects and compositions of objects uniformly.
翻译成中文就是: 将一组对象组织(Compose)成树形结构, 以表示一种 "部分-整体" 的层次结构. 组合让客户端(在很多设计模式书籍中, "客户端"代指代码的使用者) 可以统一单个对象和组合对象的处理逻辑.
举个例子解释一下. 假设有这样一个需求: 设计一个类来表示文件系统中的目录, 能方便地实现下面这些功能:
- 动态地添加, 删除某个目录下的子目录或文件;
- 统计指定目录下的文件个数;
- 统计指定目录下的文件总大小.
这里给出了这个类的骨架代码, 如下所示. 其中的核心逻辑并未实现, 可以试着自己去补充完整. 在下面的代码实现中, 把文件和目录统一用 FileSystemNode 类来表示, 并且通过 isFile 属性来区分.
public class FileSystemNode {
private String path;
private boolean isFile;
private List<FileSystemNode> subNodes = new ArrayList<>();
public FileSystemNode(String path, boolean isFile) {
this.path = path;
this.isFile = isFile;
}
public int countNumOfFiles() {
// TODO:...
}
public long countSizeOfFiles() {
// TODO:...
}
public String getPath() {
return path;
}
public void addSubNode(FileSystemNode fileOrDir) {
subNodes.add(fileOrDir);
}
public void removeSubNode(FileSystemNode fileOrDir) {
int size = subNodes.size();
int i = 0;
for (; i < size; ++i) {
if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
break;
}
}
if (i < size) {
subNodes.remove(i);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
实际上, 想要补全其中的 countNumOfFiles() 和 countSizeOfFiles() 这两个函数, 并不是件难事, 实际上这就是树上的递归遍历算法. 对于文件, 直接返回文件的个数(返回 1)或大小. 对于目录, 遍历目录中每个子目录或者文件, 递归计算它们的个数或大小, 然后求和, 就是这个目录下的文件个数和文件大小.
两个函数的代码实现贴在下面了.
public int countNumOfFiles() {
if (isFile) {
return 1;
}
int numOfFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
numOfFiles += fileOrDir.countNumOfFiles();
}
return numOfFiles;
}
public long countSizeOfFiles() {
if (isFile) {
File file = new File(path);
if (!file.exists()) return 0;
return file.length();
}
long sizeofFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
sizeofFiles += fileOrDir.countSizeOfFiles();
}
return sizeofFiles;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
单纯从功能实现角度来说, 上面的代码没有问题, 已经实现了想要的功能. 但如果我开发的是一个大型系统, 从扩展性(文件或目录可能会对应不同的操作), 业务建模(文件和目录从业务上是两个概念), 代码的可读性(文件和目录区分对待更加符合人们对业务的认知)的角度来说, 最好对文件和目录进行区分设计, 定义为 File 和 Directory 两个类.
按照这个设计思路, 对代码进行重构. 重构之后的代码如下所示:
public abstract class FileSystemNode {
protected String path;
public FileSystemNode(String path) {
this.path = path;
}
public abstract int countNumOfFiles();
public abstract long countSizeOfFiles();
public String getPath() {
return path;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
public class File extends FileSystemNode {
public File(String path) {
super(path);
}
@Override
public int countNumOfFiles() {
return 1;
}
@Override
public long countSizeOfFiles() {
java.io.File file = new java.io.File(path);
if (!file.exists()) return 0;
return file.length();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Directory extends FileSystemNode {
private List<FileSystemNode> subNodes = new ArrayList<>();
public Directory(String path) {
super(path);
}
@Override
public int countNumOfFiles() {
int numOfFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
numOfFiles += fileOrDir.countNumOfFiles();
}
return numOfFiles;
}
@Override
public long countSizeOfFiles() {
long sizeofFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
sizeofFiles += fileOrDir.countSizeOfFiles();
}
return sizeofFiles;
}
public void addSubNode(FileSystemNode fileOrDir) {
subNodes.add(fileOrDir);
}
public void removeSubNode(FileSystemNode fileOrDir) {
int size = subNodes.size();
int i = 0;
for (; i < size; ++i) {
if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
break;
}
}
if (i < size) {
subNodes.remove(i);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
文件和目录类都设计好了, 来看如何用它们来表示一个文件系统中的目录树结构. 具体的代码示例如下所示:
public class Demo {
public static void main(String[] args) {
/**
* /
* /wz/
* /wz/a.txt
* /wz/b.txt
* /wz/movies/
* /wz/movies/c.avi
* /xzg/
* /xzg/docs/
* /xzg/docs/d.txt
*/
Directory fileSystemTree = new Directory("/");
Directory node_wz = new Directory("/wz/");
Directory node_xzg = new Directory("/xzg/");
fileSystemTree.addSubNode(node_wz);
fileSystemTree.addSubNode(node_xzg);
File node_wz_a = new File("/wz/a.txt");
File node_wz_b = new File("/wz/b.txt");
Directory node_wz_movies = new Directory("/wz/movies/");
node_wz.addSubNode(node_wz_a);
node_wz.addSubNode(node_wz_b);
node_wz.addSubNode(node_wz_movies);
File node_wz_movies_c = new File("/wz/movies/c.avi");
node_wz_movies.addSubNode(node_wz_movies_c);
Directory node_xzg_docs = new Directory("/xzg/docs/");
node_xzg.addSubNode(node_xzg_docs);
File node_xzg_docs_d = new File("/xzg/docs/d.txt");
node_xzg_docs.addSubNode(node_xzg_docs_d);
System.out.println("/files num:" + fileSystemTree.countNumOfFiles());
System.out.println("/wz/ files num:" + node_wz.countNumOfFiles());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
对照着这个例子, 再重新看一下组合模式的定义: "将一组对象(文件和目录)组织成树形结构, 以表示一种 "部分-整体" 的层次结构(目录与子目录的嵌套结构). 组合模式让客户端可以统一单个对象(文件)和组合对象(目录)的处理逻辑(递归遍历)."
实际上, 刚才讲的这种组合模式的设计思路, 与其说是一种设计模式, 倒不如说是对业务场景的一种数据结构和算法的抽象. 其数据可以表示成树这种数据结构, 业务需求可以通过在树上的递归遍历算法来实现.
# 2.组合模式的应用场景举例
刚刚讲了文件系统的例子, 对于组合模式, 这里再举一个例子. 搞懂了这两个例子, 基本上就算掌握了组合模式. 在实际的项目中, 遇到类似的可以表示成树形结构的业务场景, 只要 "照葫芦画瓢" 去设计就可以了.
假设开发一个 OA 系统(办公自动化系统). 公司的组织结构包含部门和员工两种数据类型. 其中, 部门又可以包含子部门和员工. 在数据库中的表结构如下所示:

希望在内存中构建整个公司的人员架构图(部门, 子部门, 员工的隶属关系), 并且提供接口计算出部门的薪资成本(隶属于这个部门的所有员工的薪资和).
部门包含子部门和员工, 这是一种嵌套结构, 可以表示成树这种数据结构. 计算每个部门的薪资开支这样一个需求, 也可以通过在树上的遍历算法来实现. 所以从这个角度来看, 这个应用场景可以使用组合模式来设计和实现.
这个例子的代码结构跟上一个例子的很相似, 代码实现直接贴在了下面, 可以对比着看一下. 其中, HumanResource 是部门类(Department)和员工类(Employee)抽象出来的父类, 为的是能统一薪资的处理逻辑. Demo 中的代码负责从数据库中读取数据并在内存中构建组织架构图.
public abstract class HumanResource {
protected long id;
protected double salary;
public HumanResource(long id) {
this.id = id;
}
public long getId() {
return id;
}
public abstract double calculateSalary();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Employee extends HumanResource {
public Employee(long id, double salary) {
super(id);
this.salary = salary;
}
@Override
public double calculateSalary() {
return salary;
}
}
2
3
4
5
6
7
8
9
10
11
public class Department extends HumanResource {
private List<HumanResource> subNodes = new ArrayList<>();
public Department(long id) {
super(id);
}
@Override
public double calculateSalary() {
double totalSalary = 0;
for (HumanResource hr : subNodes) {
totalSalary += hr.calculateSalary();
}
this.salary = totalSalary;
return totalSalary;
}
public void addSubNode(HumanResource hr) {
subNodes.add(hr);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 构建组织架构的代码
public class Demo {
private static final long ORGANIZATION_ROOT_ID = 1001;
private DepartmentRepo departmentRepo; // 依赖注入
private EmployeeRepo employeeRepo; // 依赖注入
public void buildOrganization() {
Department rootDepartment = new Department(ORGANIZATION_ROOT_ID);
buildOrganization(rootDepartment);
}
private void buildOrganization(Department department) {
List<Long> subDepartmentIds = departmentRepo.getSubDepartmentIds(department.getId());
for (Long subDepartmentId : subDepartmentIds) {
Department subDepartment = new Department(subDepartmentId);
department.addSubNode(subDepartment);
buildOrganization(subDepartment);
}
List<Long> employeeIds = employeeRepo.getDepartmentEmployeeIds(department.getId());
for (Long employeeId : employeeIds) {
double salary = employeeRepo.getEmployeeSalary(employeeId);
department.addSubNode(new Employee(employeeId, salary));
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
再拿组合模式的定义跟这个例子对照一下: "将一组对象(员工和部门)组织成树形结构, 以表示一种 '部分-整体' 的层次结构(部门与子部门的嵌套结构). 组合模式让客户端可以统一单个对象(员工)和组合对象(部门)的处理逻辑(递归遍历) ".
重点回顾
组合模式的设计思路, 与其说是一种设计模式, 倒不如说是对业务场景的一种数据结构和算法的抽象. 其中, 数据可以表示成树这种数据结构, 业务需求可以通过在树上的递归遍历算法来实现.
组合模式, 将一组对象组织成树形结构, 将单个对象和组合对象都看做树中的节点, 以统一处理逻辑, 并且它利用树形结构的特点, 递归地处理每个子树, 依次简化代码实现. 使用组合模式的前提在于, 业务场景必须能够表示成树形结构. 所以, 组合模式的应用场景也比较局限, 它并不是一种很常用的设计模式.
# 54-享元模式(上):如何利用享元模式优化文本编辑器的内存占用?
今天再来学习一个不那么常用的模式, 享元模式(Flyweight Design Pattern). 这也是我们要学习的最后一个结构型模式.
跟其他所有的设计模式类似, 享元模式的原理和实现也非常简单. 今天会通过棋牌游戏和文本编辑器两个实际的例子来讲解. 除此之外, 还会讲到它跟单例, 缓存, 对象池的区别和联系. 下一节会剖析一下享元模式在 Java Integer, String 中的应用.
# 1.享元模式原理与实现
所谓 "享元", 顾名思义就是被共享的单元. 享元模式的意图是复用对象, 节省内存, 前提是享元对象是不可变对象.
具体来讲, 当一个系统中存在大量重复对象的时候, 如果这些重复的对象是不可变对象, 就可以利用享元模式将对象设计成享元, 在内存中只保留一份实例, 供多处代码引用. 这样可以减少内存中对象的数量, 起到节省内存的目的. 实际上, 不仅仅相同对象可以设计成享元, 对于相似对象, 也可以将这些对象中相同的部分(字段)提取出来, 设计成享元, 让这些大量相似对象引用这些享元.
稍微解释一下, 定义中的 "不可变对象" 指的是, 一旦通过构造函数初始化完成之后, 它的状态(对象的成员变量或者属性)就不会再被修改了. 所以, 不可变对象不能暴露任何 set() 等修改内部状态的方法. 之所以要求享元是不可变对象, 那是因为它会被多处代码共享使用, 避免一处代码对享元进行了修改, 影响到其他使用它的代码.
接下来通过一个简单的例子解释一下享元模式.
假设开发一个棋牌游戏(比如象棋). 一个游戏厅中有成千上万个 "房间", 每个房间对应一个棋局. 棋局要保存每个棋子的数据, 比如: 棋子类型(将, 相, 士, 炮等), 棋子颜色(红方, 黑方), 棋子在棋局中的位置. 利用这些数据, 就能显示一个完整的棋盘给玩家. 具体的代码如下所示. 其中, ChessPiece 类表示棋子, ChessBoard 类表示一个棋局, 里面保存了象棋中 30 个棋子的信息.
public class ChessPiece {// 棋子
private int id;
private String text;
private Color color;
private int positionX;
private int positionY;
public ChessPiece(int id, String text, Color color, int positionX, int positionY) {
this.id = id;
this.text = text;
this.color = color;
this.positionX = positionX;
this.positionY = positionX;
}
public static enum Color {
RED, BLACK
}
// ...省略其他属性和getter/setter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ChessBoard {// 棋局
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
public ChessBoard() {
init();
}
private void init() {
chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
chessPieces.put(2, new ChessPiece(2,"馬", ChessPiece.Color.BLACK, 0, 1));
//...省略摆放其他棋子的代码...
}
public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
为了记录每个房间当前的棋局情况, 需要给每个房间都创建一个 ChessBoard 棋局对象. 因为游戏大厅中有成千上万的房间(实际上, 百万人同时在线的游戏大厅也有很多), 那保存这么多棋局对象就会消耗大量的内存. 有没有什么办法来节省内存呢?
这个时候, 享元模式就可以派上用场了. 像刚刚的实现方式, 在内存中会有大量的相似对象. 这些相似对象的 id, text, color 都是相同的, 唯独 positionX, positionY 不同. 实际上, 可以将棋子的 id, text, color 属性拆分出来, 设计成独立的类, 并且作为享元供多个棋盘复用. 这样棋盘只需要记录每个棋子的位置信息就可以了. 具体的代码实现如下所示:
// 享元类
public class ChessPieceUnit {
private int id;
private String text;
private Color color;
public ChessPieceUnit(int id, String text, Color color) {
this.id = id;
this.text = text;
this.color = color;
}
public static enum Color {
RED, BLACK
}
// ...省略其他属性和getter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ChessPieceUnitFactory {
private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();
static {
pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK));
//...省略摆放其他棋子的代码...
}
public static ChessPieceUnit getChessPiece(int chessPieceId) {
return pieces.get(chessPieceId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
public class ChessPiece {
private ChessPieceUnit chessPieceUnit;
private int positionX;
private int positionY;
public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
this.chessPieceUnit = unit;
this.positionX = positionX;
this.positionY = positionY;
}
// 省略getter, setter方法
}
2
3
4
5
6
7
8
9
10
11
12
public class ChessBoard {
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
public ChessBoard() {
init();
}
private void init() {
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(1), 0,0));
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(2), 1,0));
//...省略摆放其他棋子的代码...
}
public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在上面的代码实现中, 利用工厂类来缓存 ChessPieceUnit 信息(也就是 id, text, color). 通过工厂类获取到的 ChessPieceUnit 就是享元. 所有的 ChessBoard 对象共享这 30 个 ChessPieceUnit 对象(因为象棋中只有 30 个棋子). 在使用享元模式之前, 记录 1 万个棋局, 要创建 30 万(30*1 万)个棋子的 ChessPieceUnit 对象. 利用享元模式, 只需要创建 30 个享元对象供所有棋局共享使用即可, 大大节省了内存.
那享元模式的原理讲完了, 来总结一下它的代码结构. 实际上, 它的代码实现非常简单, 主要是通过工厂模式, 在工厂类中, 通过一个 Map 来缓存已经创建过的享元对象, 来达到复用的目的.
# 2.享元模式在文本编辑器中的应用
弄懂了享元模式的原理和实现之后, 再来看另外一个例子, 也就是文章标题中给出的: 如何利用享元模式来优化文本编辑器的内存占用?
为了简化需求背景, 假设这个文本编辑器只实现了文字编辑功能, 不包含图片, 表格等复杂的编辑功能. 对于简化之后的文本编辑器, 要在内存中表示一个文本文件, 只需要记录文字和格式两部分信息就可以了, 其中格式又包括文字的字体, 大小, 颜色等信息.
尽管在实际的文档编写中, 一般都是按照文本类型(标题, 正文...)来设置文字的格式, 标题是一种格式, 正文是另一种格式等等. 但是, 从理论上讲, 可以给文本文件中的每个文字都设置不同的格式. 为了实现如此灵活的格式设置, 并且代码实现又不过于太复杂, 把每个文字都当作一个独立的对象来看待, 并且在其中包含它的格式信息. 具体的代码示例如下所示:
public class Character {// 文字
private char c;
private Font font;
private int size;
private int colorRGB;
public Character(char c, Font font, int size, int colorRGB) {
this.c = c;
this.font = font;
this.size = size;
this.colorRGB = colorRGB;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Editor {
private List<Character> chars = new ArrayList<>();
public void appendCharacter(char c, Font font, int size, int colorRGB) {
Character character = new Character(c, font, size, colorRGB);
chars.add(character);
}
}
2
3
4
5
6
7
8
在文本编辑器中, 每敲一个文字, 都会调用 Editor 类中的 appendCharacter() 方法, 创建一个新的 Character 对象, 保存到 chars 数组中. 如果一个文本文件中, 有上万, 十几万, 几十万的文字, 那就要在内存中存储这么多 Character 对象. 那有没有办法可以节省一点内存呢?
实际上, 在一个文本文件中, 用到的字体格式不会太多, 毕竟不大可能有人把每个文字都设置成不同的格式. 所以, 对于字体格式, 可以将它设计成享元, 让不同的文字共享使用. 按照这个设计思路, 对上面的代码进行重构. 重构后的代码如下所示:
public class CharacterStyle {
private Font font;
private int size;
private int colorRGB;
public CharacterStyle(Font font, int size, int colorRGB) {
this.font = font;
this.size = size;
this.colorRGB = colorRGB;
}
@Override
public boolean equals(Object o) {
CharacterStyle otherStyle = (CharacterStyle) o;
return font.equals(otherStyle.font)
&& size <mark> otherStyle.size
&& colorRGB </mark> otherStyle.colorRGB;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CharacterStyleFactory {
private static final List<CharacterStyle> styles = new ArrayList<>();
public static CharacterStyle getStyle(Font font, int size, int colorRGB) {
CharacterStyle newStyle = new CharacterStyle(font, size, colorRGB);
for (CharacterStyle style : styles) {
if (style.equals(newStyle)) {
return style;
}
}
styles.add(newStyle);
return newStyle;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Character {
private char c;
private CharacterStyle style;
public Character(char c, CharacterStyle style) {
this.c = c;
this.style = style;
}
}
2
3
4
5
6
7
8
9
public class Editor {
private List<Character> chars = new ArrayList<>();
public void appendCharacter(char c, Font font, int size, int colorRGB) {
Character character = new Character(c, CharacterStyleFactory.getStyle(font, size, colorRGB));
chars.add(character);
}
}
2
3
4
5
6
7
8
# 3.享元模式vs单例,缓存,对象池
在上面的讲解中, 多次提到"共享", "缓存", "复用" 这些字眼, 那它跟单例, 缓存, 对象池这些概念有什么区别呢? 来简单对比一下.
先来看享元模式跟单例的区别.
在单例模式中, 一个类只能创建一个对象, 而在享元模式中, 一个类可以创建多个对象, 每个对象被多处代码引用共享. 实际上, 享元模式有点类似于之前讲到的单例的变体: 多例.
前面也多次提到, 区别两种设计模式, 不能光看代码实现, 而是要看设计意图, 也就是要解决的问题. 尽管从代码实现上来看, 享元模式和多例有很多相似之处, 但从设计意图上来看, 它们是完全不同的. 应用享元模式是为了对象复用, 节省内存, 而应用多例模式是为了限制对象的个数.
再来看享元模式跟缓存的区别.
在享元模式的实现中, 通过工厂类来 "缓存" 已经创建好的对象. 这里的 "缓存" 实际上是 "存储" 的意思, 跟平时所说的 "数据库缓存", "CPU 缓存", "MemCache 缓存" 是两回事. 平时所讲的缓存, 主要是为了提高访问效率, 而非复用.
最后来看享元模式跟对象池的区别.
对象池, 连接池(比如数据库连接池), 线程池等也是为了复用, 那它们跟享元模式有什么区别呢?
像 C++ 这样的编程语言, 内存的管理是由程序员负责的. 为了避免频繁地进行对象创建和释放导致内存碎片, 可以预先申请一片连续的内存空间, 也就是这里说的对象池. 每次创建对象时, 从对象池中直接取出一个空闲对象来使用, 对象使用完成之后, 再放回到对象池中以供后续复用, 而非直接释放掉.
虽然对象池, 连接池, 线程池, 享元模式都是为了复用, 但如果再细致地抠一抠 "复用" 这个字眼的话, 对象池, 连接池, 线程池等池化技术中的 "复用" 和享元模式中的 "复用" 实际上是不同的概念.
池化技术中的 "复用" 可以理解为 "重复使用", 主要目的是节省时间(比如从数据库池中取一个连接, 不需要重新创建) . 在任意时刻, 每一个对象, 连接, 线程, 并不会被多处使用, 而是被一个使用者独占, 当使用完成之后, 放回到池中, 再由其他使用者重复利用. 享元模式中的 "复用" 可以理解为 "共享使用", 在整个生命周期中, 都是被所有使用者共享的, 主要目的是节省空间.
# 4.重点回顾
1.享元模式的原理
所谓 "享元", 顾名思义就是被共享的单元. 享元模式的意图是复用对象, 节省内存, 前提是享元对象是不可变对象. 具体来讲, 当一个系统中存在大量重复对象的时候, 就可以利用享元模式, 将对象设计成享元, 在内存中只保留一份实例, 供多处代码引用, 这样可以减少内存中对象的数量, 以起到节省内存的目的. 实际上, 不仅仅相同对象可以设计成享元, 对于相似对象, 也可以将这些对象中相同的部分(字段), 提取出来设计成享元, 让这些大量相似对象引用这些享元.
2.享元模式的实现
享元模式的代码实现非常简单, 主要是通过工厂模式, 在工厂类中, 通过一个 Map 或者 List 来缓存已经创建好的享元对象, 以达到复用的目的.
3.享元模式 VS 单例, 缓存, 对象池
前面也多次提到, 区别两种设计模式, 不能光看代码实现, 而是要看设计意图, 也就是要解决的问题. 这里的区别也不例外.
可以用简单几句话来概括一下它们之间的区别. 应用单例模式是为了保证对象全局唯一. 应用享元模式是为了实现对象复用, 节省内存. 缓存是为了提高访问效率, 而非复用. 池化技术中的 "复用" 理解为 "重复使用", 主要是为了节省时间.
# 55-享元模式(下):剖析享元模式在Java Integer, String中的应用
本节来看看享元模式在 Java Integer, String 中的应用.
# 1.享元模式在Java Integer中的应用
先来看下面这样一段代码. 可以先思考下, 这段代码会输出什么样的结果.
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 <mark> i2);
System.out.println(i3 </mark> i4);
2
3
4
5
6
如果不熟悉 Java 语言, 可能会觉得, i1 和 i2 值都是 56, i3 和 i4 值都是 129, i1 跟 i2 值相等, i3 跟 i4 值相等, 所以输出结果应该是两个 true. 这样的分析是不对的, 主要还是因为你对 Java 语法不熟悉. 要正确地分析上面的代码, 需要弄清楚下面两个问题:
- 如何判定两个 Java 对象是否相等(也就代码中的 "==" 操作符的含义)?
- 什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)?
Java 为基本数据类型提供了对应的包装器类型. 所谓的自动装箱, 就是自动将基本数据类型转换为包装器类型. 所谓的自动拆箱, 也就是自动将包装器类型转化为基本数据类型. 具体的代码示例如下所示:
Integer i = 56; // 自动装箱
int j = i; // 自动拆箱
2
数值 56 是基本数据类型 int, 当赋值给包装器类型(Integer)变量的时候, 触发自动装箱操作, 创建一个 Integer 类型的对象, 并且赋值给变量 i. 其底层相当于执行了下面这条语句:
Integer i = 59; 底层执行了: Integer i = Integer.valueOf(59);
反过来, 当把包装器类型的变量 i, 赋值给基本数据类型变量 j 的时候, 触发自动拆箱操作, 将 i 中的数据取出, 赋值给 j. 其底层相当于执行了下面这条语句:
int j = i; 底层执行了: int j = i.intValue();
弄清楚了自动装箱和自动拆箱, 再来看如何判定两个对象是否相等? 在此之前先要搞清楚, Java 对象在内存中是如何存储的. 通过下面这个例子来说明一下.
User a = new User(123, 23); // id=123, age=23
针对这条语句, 我画了一张内存存储结构图, 如下所示. a 存储的值是 User 对象的内存地址, 在图中就表现为 a 指向 User 对象.

当通过 "==" 来判定两个对象是否相等的时候, 实际上是在判断两个局部变量存储的地址是否相同, 换句话说, 是在判断两个局部变量是否指向相同的对象.
了解了 Java 的这几个语法之后, 重新看一下开头的那段代码.
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 <mark> i2);
System.out.println(i3 </mark> i4);
2
3
4
5
6
前 4 行赋值语句都会触发自动装箱操作, 也就是会创建 Integer 对象并且赋值给 i1, i2, i3, i4 这四个变量. 根据刚刚的讲解, i1, i2 尽管存储的数值相同, 都是 56, 但是指向不同的 Integer 对象, 所以通过 "==" 来判定是否相同的时候, 会返回 false. 同理, i3i4 判定语句也会返回 false.
不过, 上面的分析还是不对, 答案并非是两个 false, 而是一个 true, 一个 false. 看到这里, 你可能会比较纳闷了. 实际上, 这正是因为 Integer 用到了享元模式来复用对象, 才导致了这样的运行结果. 当通过自动装箱, 也就是调用 valueOf() 来创建 Integer 对象的时候, 如果要创建的 Integer 对象的值在 -128 到 127 之间, 会从 IntegerCache 类中直接返回, 否则才调用 new 方法创建. 看代码更加清晰一些, Integer 类的 valueOf() 函数的具体代码如下所示:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
2
3
4
5
实际上, 这里的 IntegerCache 相当于上一节讲的生成享元对象的工厂类, 只不过名字不叫 xxxFactory 而已. 来看它的具体代码实现.
/**
* Cache to support the object identity semantics of autoboxing for values between
* -128 and 127 (inclusive) as required by JLS.
*
* The cache is initialized on first usage. The size of the cache
* may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
* During VM initialization, java.lang.Integer.IntegerCache.high property
* may be set and saved in the private system properties in the
* sun.misc.VM class.
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?
在 IntegerCache 的代码实现中, 当这个类被加载的时候, 缓存的享元对象会被集中一次性创建好. 毕竟整型值太多了, 不可能在 IntegerCache 类中预先创建好所有的整型值, 这样既占用太多内存, 也使得加载 IntegerCache 类的时间过长. 所以只能选择缓存对于大部分应用来说最常用的整型值, 也就是一个字节的大小(-128 到 127 之间的数据).
实际上, JDK 也提供了方法可以自定义缓存的最大值, 有下面两种方式. 如果你通过分析应用的 JVM 内存占用情况, 发现 -128 到 255 之间的数据占用的内存比较多, 你就可以用如下方式, 将缓存的最大值从 127 调整到 255. 不过, 这里注意一下, JDK 并没有提供设置最小值的方法.
// 方法一:
-Djava.lang.Integer.IntegerCache.high=255
// 方法二:
-XX:AutoBoxCacheMax=255
2
3
4
现在再回到最开始的问题, 因为 56 处于 -128 和 127 之间, i1 和 i2 会指向相同的享元对象, 所以 i1 <mark> i2 返回 true. 而 129 大于 127, 并不会被缓存, 每次都会创建一个全新的对象, 也就是说, i3 和 i4 指向不同的 Integer 对象, 所以 i3 </mark> i4 返回 false.
实际上, 除了 Integer 类型之外, 其他包装器类型, 比如 Long, Short, Byte 等, 也都利用了享元模式来缓存 -128 到 127 之间的数据.
在平时的开发中, 对于下面这样三种创建整型对象的方式, 优先使用后两种.
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);
2
3
第一种创建方式并不会使用到 IntegerCache, 而后面两种创建方法可以利用 IntegerCache 缓存, 返回共享的对象, 以达到节省内存的目的. 举一个极端一点的例子, 假设程序需要创建 1 万个 -128 到 127 之间的 Integer 对象. 使用第一种创建方式, 需要分配 1 万个 Integer 对象的内存空间; 使用后两种创建方式, 最多只需要分配 256 个 Integer 对象的内存空间.
# 2.享元模式在Java String中的应用
刚刚讲了享元模式在 Java Integer 类中的应用, 现在再来看下, 享元模式在 Java String 类中的应用. 同样, 还是先来看一段代码, 你觉得这段代码输出的结果是什么呢?
String s1 = "小争哥";
String s2 = "小争哥";
String s3 = new String("小争哥");
System.out.println(s1 <mark> s2);
System.out.println(s1 </mark> s3);
2
3
4
5
6
上面代码的运行结果是: 一个 true, 一个 false. 跟 Integer 类的设计思路相似, String 类利用享元模式来复用相同的字符串常量(也就是代码中的"小争哥") . JVM 会专门开辟一块存储区来存储字符串常量, 这块存储区叫作 "字符串常量池". 上面代码对应的内存存储结构如下所示:

不过, String 类的享元模式的设计, 跟 Integer 类稍微有些不同. Integer 类中要共享的对象, 是在类加载的时候, 就集中一次性创建好的. 但对于字符串来说, 没法事先知道要共享哪些字符串常量, 所以没办法事先创建好, 只能在某个字符串常量第一次被用到的时候, 存储到常量池中, 当之后再用到的时候, 直接引用常量池中已经存在的即可, 就不需要再重新创建了.
重点回顾
在 Java Integer 的实现中, -128 到 127 之间的整型对象会被事先创建好, 缓存在 IntegerCache 类中. 当我们使用自动装箱或者 valueOf() 来创建这个数值区间的整型对象时, 会复用 IntegerCache 类事先创建好的对象. 这里的 IntegerCache 类就是享元工厂类, 事先创建好的整型对象就是享元对象.
在 Java String 类的实现中, JVM 开辟一块存储区专门存储字符串常量, 这块存储区叫作字符串常量池, 类似于 Integer 中的 IntegerCache. 不过, 跟 IntegerCache 不同的是, 它并非事先创建好需要共享的对象, 而是在程序的运行期间, 根据需要来创建和缓存字符串常量.
除此之外, 这里再补充一下.
实际上, 享元模式对 JVM 的垃圾回收并不友好. 因为享元工厂类一直保存了对享元对象的引用, 这就导致享元对象在没有任何代码使用的情况下, 也并不会被 JVM 垃圾回收机制自动回收掉. 因此在某些情况下, 如果对象的生命周期很短, 也不会被密集使用, 利用享元模式反倒可能会浪费更多的内存. 所以, 除非经过线上验证, 利用享元模式真的可以大大节省内存, 否则就不要过度使用这个模式, 为了一点点内存的节省而引入一个复杂的设计模式, 得不偿失啊.
# 56-观察者模式(上):详解各种应用场景下观察者模式的不同实现方式
23 种经典的设计模式分为三类: 创建型, 结构型, 行为型. 前面已经学习了创建型和结构型, 从今天起开始学习行为型设计模式. 创建型设计模式主要解决 "对象的创建" 问题, 结构型设计模式主要解决 "类或对象的组合或组装 "问题, 那行为型设计模式主要解决的就是 "类或对象之间的交互" 问题.
行为型设计模式比较多, 有 11 个, 几乎占了 23 种经典设计模式的一半. 它们分别是: 观察者模式, 模板模式, 策略模式, 职责链模式, 状态模式, 迭代器模式, 访问者模式, 备忘录模式, 命令模式, 解释器模式, 中介模式.
今天学习第一个行为型设计模式, 也是在实际的开发中用得比较多的一种模式: 观察者模式. 根据应用场景的不同, 观察者模式会对应不同的代码实现方式: 有同步阻塞的实现方式, 也有异步非阻塞的实现方式; 有进程内的实现方式, 也有跨进程的实现方式. 今天会重点讲解原理, 实现, 应用场景. 下一节会一块实现一个基于观察者模式的异步非阻塞的 EventBus, 加深你对这个模式的理解.
# 1.原理及应用场景剖析
观察者模式(Observer Design Pattern)也被称为发布订阅模式(Publish-Subscribe Design Pattern). 在 GoF 的《设计模式》一书中, 它的定义是这样的:
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
翻译成中文就是: 在对象之间定义一个一对多的依赖, 当一个对象状态改变的时候, 所有依赖的对象都会自动收到通知.
一般情况下, 被依赖的对象叫作被观察者(Observable), 依赖的对象叫作观察者(Observer). 不过, 在实际的项目开发中, 这两种对象的称呼是比较灵活的, 有各种不同的叫法, 比如: Subject-Observer, Publisher-Subscriber, Producer-Consumer, EventEmitter-EventListener, Dispatcher-Listener. 不管怎么称呼, 只要应用场景符合刚刚给出的定义, 都可以看作观察者模式.
实际上, 观察者模式是一个比较抽象的模式, 根据不同的应用场景和需求, 有完全不同的实现方式. 先来看其中最经典的一种实现方式. 这也是在讲到这种模式的时候, 很多书籍或资料给出的最常见的实现方式. 具体的代码如下所示:
public interface Subject {
void registerObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(Message message);
}
2
3
4
5
public interface Observer {
void update(Message message);
}
2
3
public class ConcreteSubject implements Subject {
private List<Observer> observers = new ArrayList<Observer>();
@Override
public void registerObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(Message message) {
for (Observer observer : observers) {
observer.update(message);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ConcreteObserverOne implements Observer {
@Override
public void update(Message message) {
// TODO: 获取消息通知, 执行自己的逻辑...
System.out.println("ConcreteObserverOne is notified.");
}
}
2
3
4
5
6
7
public class ConcreteObserverTwo implements Observer {
@Override
public void update(Message message) {
// TODO: 获取消息通知, 执行自己的逻辑...
System.out.println("ConcreteObserverTwo is notified.");
}
}
2
3
4
5
6
7
public class Demo {
public static void main(String[] args) {
ConcreteSubject subject = new ConcreteSubject();
subject.registerObserver(new ConcreteObserverOne());
subject.registerObserver(new ConcreteObserverTwo());
subject.notifyObservers(new Message());
}
}
2
3
4
5
6
7
8
实际上, 上面的代码算是观察者模式的 "模板代码" , 只能反映大体的设计思路. 在真实的软件开发中, 并不需要照搬上面的模板代码. 观察者模式的实现方法各式各样, 函数, 类的命名等会根据业务场景的不同有很大的差别, 比如 register 函数还可以叫作 attach, remove 函数还可以叫作 detach 等等. 不过, 万变不离其宗, 设计思路都是差不多的.
原理和代码实现都非常简单, 也比较好理解, 不需要过多的解释. 还是通过一个具体的例子来重点讲一下, 什么情况下需要用到这种设计模式? 或者说这种设计模式能解决什么问题呢?
假设在开发一个 P2P 投资理财系统, 用户注册成功之后, 会给用户发放投资体验金. 代码实现大致是下面这个样子的:
public class UserController {
private UserService userService; // 依赖注入
private PromotionService promotionService; // 依赖注入
public Long register(String telephone, String password) {
// 省略输入参数的校验代码
// 省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
promotionService.issueNewUserExperienceCash(userId);
return userId;
}
}
2
3
4
5
6
7
8
9
10
11
12
虽然注册接口做了两件事情, 注册和发放体验金, 违反单一职责原则, 但如果没有扩展和修改的需求, 现在的代码实现是可以接受的. 如果非得用观察者模式, 就需要引入更多的类和更加复杂的代码结构, 反倒是一种过度设计.
相反, 如果需求频繁变动, 比如用户注册成功之后, 不再发放体验金, 而是改为发放优惠券, 并且还要给用户发送一封 "欢迎注册成功" 的站内信. 这种情况下, 就需要频繁地修改 register() 函数中的代码, 违反开闭原则. 而且, 如果注册成功之后需要执行的后续操作越来越多, 那 register() 函数的逻辑会变得越来越复杂, 也就影响到代码的可读性和可维护性.
这个时候, 观察者模式就能派上用场了. 利用观察者模式, 对上面的代码进行了重构. 重构之后的代码如下所示:
public interface RegObserver {
void handleRegSuccess(long userId);
}
2
3
public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入
@Override
public void handleRegSuccess(long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}
2
3
4
5
6
7
8
public class RegNotificationObserver implements RegObserver {
private NotificationService notificationService;
@Override
public void handleRegSuccess(long userId) {
notificationService.sendInboxMessage(userId, "Welcome...");
}
}
2
3
4
5
6
7
8
public class UserController {
private UserService userService; // 依赖注入
private List<RegObserver> regObservers = new ArrayList<>();
// 一次性设置好, 之后也不可能动态的修改
public void setRegObservers(List<RegObserver> observers) {
regObservers.addAll(observers);
}
public Long register(String telephone, String password) {
// 省略输入参数的校验代码
// 省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
for (RegObserver observer : regObservers) {
observer.handleRegSuccess(userId);
}
return userId;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
当需要添加新的观察者的时候, 比如用户注册成功之后, 推送用户注册信息给大数据征信系统, 基于观察者模式的代码实现, UserController 类的 register() 函数完全不需要修改, 只需要再添加一个实现了 RegObserver 接口的类, 并且通过 setRegObservers() 函数将它注册到 UserController 类中即可.
不过, 你可能会说, 当把发送体验金替换为发送优惠券的时候, 需要修改 RegPromotionObserver 类中 handleRegSuccess() 函数的代码, 这还是违反开闭原则呀? 你说得没错, 不过相对于 register() 函数来说, handleRegSuccess() 函数的逻辑要简单很多, 修改更不容易出错, 引入 bug 的风险更低.
前面已经学习了很多设计模式, 不知道你有没有发现, 实际上, 设计模式要干的事情就是解耦. 创建型模式是将创建和使用代码解耦, 结构型模式是将不同功能代码解耦, 行为型模式是将不同的行为代码解耦, 具体到观察者模式, 它是将观察者和被观察者代码解耦. 借助设计模式, 利用更好的代码结构, 将一大坨代码拆分成职责更单一的小类, 让其满足开闭原则, 高内聚松耦合等特性, 以此来控制和应对代码的复杂性, 提高代码的可扩展性.
# 2.基于不同应用场景的不同实现方式
观察者模式的应用场景非常广泛, 小到代码层面的解耦, 大到架构层面的系统解耦, 再或者一些产品的设计思路, 都有这种模式的影子, 比如, 邮件订阅, RSS Feeds, 本质上都是观察者模式.
不同的应用场景和需求下, 这个模式也有截然不同的实现方式, 开篇时也提到, 有同步阻塞的实现方式, 也有异步非阻塞的实现方式; 有进程内的实现方式, 也有跨进程的实现方式.
之前讲到的实现方式, 从刚刚的分类方式上来看, 它是一种同步阻塞的实现方式. 观察者和被观察者代码在同一个线程内执行, 被观察者一直阻塞, 直到所有的观察者代码都执行完成之后, 才执行后续的代码. 对照上面讲到的用户注册的例子, register() 函数依次调用执行每个观察者的 handleRegSuccess() 函数, 等到都执行完成之后, 才会返回结果给客户端.
如果注册接口是一个调用比较频繁的接口, 对性能非常敏感, 希望接口的响应时间尽可能短, 那可以将同步阻塞的实现方式改为异步非阻塞的实现方式, 以此来减少响应时间. 具体来讲, 当 userService.register() 函数执行完成之后, 启动一个新的线程来执行观察者的 handleRegSuccess() 函数, 这样 userController.register() 函数就不需要等到所有的 handleRegSuccess() 函数都执行完成之后才返回结果给客户端. userController.register() 函数从执行 3 个 SQL 语句才返回, 减少到只需要执行 1 个 SQL 语句就返回, 响应时间粗略来讲减少为原来的 1/3.
那如何实现一个异步非阻塞的观察者模式呢? 简单一点的做法是, 在每个 handleRegSuccess() 函数中, 创建一个新的线程执行代码. 不过还有更加优雅的实现方式, 那就是基于 EventBus 来实现. 今天就不展开讲解了. 下一讲会借鉴 Google Guava EventBus 框架的设计思想, 手把手带你开发一个支持异步非阻塞的 EventBus 框架. 它可以复用在任何需要异步非阻塞观察者模式的应用场景中.
刚刚讲到的两个场景, 不管是同步阻塞实现方式还是异步非阻塞实现方式, 都是进程内的实现方式. 如果用户注册成功之后, 需要发送用户信息给大数据征信系统, 而大数据征信系统是一个独立的系统, 跟它之间的交互是跨不同进程的, 那如何实现一个跨进程的观察者模式呢?
如果大数据征信系统提供了发送用户注册信息的 RPC 接口, 仍然可以沿用之前的实现思路, 在 handleRegSuccess() 函数中调用 RPC 接口来发送数据. 但还有更加优雅, 更加常用的一种实现方式, 那就是基于消息队列(Message Queue, 比如 ActiveMQ)来实现.
当然, 这种实现方式也有弊端, 那就是需要引入一个新的系统(消息队列), 增加了维护成本. 不过, 它的好处也非常明显. 在原来的实现方式中, 观察者需要注册到被观察者中, 被观察者需要依次遍历观察者来发送消息. 而基于消息队列的实现方式, 被观察者和观察者解耦更加彻底, 两部分的耦合更小. 被观察者完全不感知观察者, 同理观察者也完全不感知被观察者. 被观察者只管发送消息到消息队列, 观察者只管从消息队列中读取消息来执行相应的逻辑.
重点回顾
设计模式要干的事情就是解耦, 创建型模式是将创建和使用代码解耦, 结构型模式是将不同功能代码解耦, 行为型模式是将不同的行为代码解耦, 具体到观察者模式, 它将观察者和被观察者代码解耦. 借助设计模式, 我们利用更好的代码结构, 将一大坨代码拆分成职责更单一的小类, 让其满足开闭原则, 高内聚低耦合等特性, 以此来控制和应对代码的复杂性, 提高代码的可扩展性.
观察者模式的应用场景非常广泛, 小到代码层面的解耦, 大到架构层面的系统解耦, 再或者一些产品的设计思路, 都有这种模式的影子, 比如, 邮件订阅, RSS Feeds, 本质上都是观察者模式. 不同的应用场景和需求下, 这个模式也有截然不同的实现方式, 有同步阻塞的实现方式, 也有异步非阻塞的实现方式; 有进程内的实现方式, 也有跨进程的实现方式.
# 57-观察者模式(下):如何实现一个异步非阻塞的EventBus框架?
上一节学习了观察者模式的原理, 实现, 应用场景, 重点介绍了不同应用场景下, 几种不同的实现方式, 包括: 同步阻塞, 异步非阻塞, 进程内, 进程间的实现方式.
同步阻塞是最经典的实现方式, 主要是为了代码解耦; 异步非阻塞除了能实现代码解耦之外, 还能提高代码的执行效率; 进程间的观察者模式解耦更加彻底, 一般是基于消息队列来实现, 用来实现不同进程间的被观察者和观察者之间的交互.
本节通过异步非阻塞的观察者模式, 实现一个类似 Google Guava EventBus 的通用框架. 等你学完本节课之后, 你会发现, 实现一个框架也并非一件难事.
# 1.异步非阻塞观察者模式的简易实现
上一节讲到, 对于异步非阻塞观察者模式, 如果只是实现一个简易版本, 不考虑任何通用性, 复用性, 实际上是非常容易的.
有两种实现方式. 其中一种是: 在每个 handleRegSuccess() 函数中创建一个新的线程执行代码逻辑; 另一种是: 在 UserController 的 register() 函数中使用线程池来执行每个观察者的 handleRegSuccess() 函数. 两种实现方式的具体代码如下所示:
// 第一种实现方式, 其他类代码不变, 就没有再重复罗列
public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入
@Override
public void handleRegSuccess(long userId) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
promotionService.issueNewUserExperienceCash(userId);
}
});
thread.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 第二种实现方式, 其他类代码不变, 就没有再重复罗列
public class UserController {
private UserService userService; // 依赖注入
private List<RegObserver> regObservers = new ArrayList<>();
private Executor executor;
public UserController(Executor executor) {
this.executor = executor;
}
public void setRegObservers(List<RegObserver> observers) {
regObservers.addAll(observers);
}
public Long register(String telephone, String password) {
// 省略输入参数的校验代码
// 省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
for (RegObserver observer : regObservers) {
executor.execute(new Runnable() {
@Override
public void run() {
observer.handleRegSuccess(userId);
}
});
}
return userId;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
对于第一种实现方式, 频繁地创建和销毁线程比较耗时, 并且并发线程数无法控制, 创建过多的线程会导致堆栈溢出. 第二种实现方式, 尽管利用了线程池解决了第一种实现方式的问题, 但线程池, 异步执行逻辑都耦合在了 register() 函数中, 增加了这部分业务代码的维护成本.
如果需求更加极端一点, 需要在同步阻塞和异步非阻塞之间灵活切换, 那就要不停地修改 UserController 的代码. 除此之外, 如果在项目中, 不止一个业务模块需要用到异步非阻塞观察者模式, 那这样的代码实现也无法做到复用.
我们知道, 框架的作用有: 隐藏实现细节, 降低开发难度, 做到代码复用, 解耦业务与非业务代码, 让程序员聚焦业务开发. 针对异步非阻塞观察者模式, 也可以将它抽象成框架来达到这样的效果, 而这个框架就是本节要讲的 EventBus.
# 2.EventBus框架功能需求介绍
EventBus 翻译为 "事件总线", 它提供了实现观察者模式的骨架代码. 可以基于此框架, 非常容易地在自己的业务场景中实现观察者模式, 不需要从零开始开发. 其中, Google Guava EventBus 就是一个比较著名的 EventBus 框架, 它不仅支持异步非阻塞模式, 同时也支持同步阻塞模式.
现在就通过例子来看一下, Guava EventBus 具有哪些功能. 还是上节课那个用户注册的例子, 用 Guava EventBus 重新实现一下, 代码如下所示:
public class UserController {
private UserService userService; // 依赖注入
private EventBus eventBus;
private static final int DEFAULT_EVENTBUS_THREAD_POOL_SIZE = 20;
public UserController() {
// eventBus = new EventBus(); // 同步阻塞模式
eventBus = new AsyncEventBus(Executors.newFixedThreadPool(DEFAULT_EVENTBUS_THREAD_POOL_SIZE)); // 异步非阻塞模式
}
public void setRegObservers(List<Object> observers) {
for (Object observer : observers) {
eventBus.register(observer);
}
}
public Long register(String telephone, String password) {
// 省略输入参数的校验代码
// 省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
eventBus.post(userId);
return userId;
}
}
public class RegPromotionObserver {
private PromotionService promotionService; // 依赖注入
@Subscribe
public void handleRegSuccess(long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}
public class RegNotificationObserver {
private NotificationService notificationService;
@Subscribe
public void handleRegSuccess(long userId) {
notificationService.sendInboxMessage(userId, "...");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
利用 EventBus 框架实现的观察者模式, 跟从零开始编写的观察者模式相比, 从大的流程上来说, 实现思路大致一样, 都需要定义 Observer, 并且通过 register() 函数注册 Observer, 也都需要通过调用某个函数(比如, EventBus 中的 post() 函数)来给 Observer 发送消息(在 EventBus 中消息被称作事件 event) .
但在实现细节方面, 它们又有些区别. 基于 EventBus, 就不需要定义 Observer 接口, 任意类型的对象都可以注册到 EventBus 中, 通过 @Subscribe 注解来标明类中哪个函数可以接收被观察者发送的消息.
接下来, 详细地讲一下, Guava EventBus 的几个主要的类和函数.
EventBus, AsyncEventBus
Guava EventBus 对外暴露的所有可调用接口, 都封装在 EventBus 类中. 其中, EventBus 实现了同步阻塞的观察者模式, AsyncEventBus 继承自 EventBus, 提供了异步非阻塞的观察者模式. 具体使用方式如下所示:
EventBus eventBus = new EventBus(); // 同步阻塞模式
EventBus eventBus = new AsyncEventBus(Executors.newFixedThreadPool(8)); // 异步阻塞模式
2
register()函数
EventBus 类提供了 register() 函数用来注册观察者. 具体的函数定义如下所示. 它可以接受任何类型(Object)的观察者. 而在经典的观察者模式的实现中, register() 函数必须接受实现了同一 Observer 接口的类对象.
public void register(Object object);
unregister()函数
相对于 register() 函数, unregister() 函数用来从 EventBus 中删除某个观察者. 就不多解释了, 具体的函数定义如下所示:
public void unregister(Object object);
post()函数
EventBus 类提供了 post() 函数, 用来给观察者发送消息. 具体的函数定义如下所示:
public void post(Object event);
跟经典的观察者模式的不同之处在于, 当调用 post() 函数发送消息的时候, 并非把消息发送给所有的观察者, 而是发送给可匹配的观察者. 所谓可匹配指的是, 能接收的消息类型是发送消息(post 函数定义中的 event)类型的父类. 举个例子来解释一下.
比如, AObserver 能接收的消息类型是 XMsg, BObserver 能接收的消息类型是 YMsg, CObserver 能接收的消息类型是 ZMsg. 其中, XMsg 是 YMsg 的父类. 当如下发送消息的时候, 相应能接收到消息的可匹配观察者如下所示:
XMsg xMsg = new XMsg();
YMsg yMsg = new YMsg();
ZMsg zMsg = new ZMsg();
post(xMsg); => AObserver接收到消息
post(yMsg); => AObserver, BObserver接收到消息
post(zMsg); => CObserver接收到消息
2
3
4
5
6
你可能会问, 每个 Observer 能接收的消息类型是在哪里定义的呢? 来看下 Guava EventBus 最特别的一个地方, 那就是 @Subscribe 注解.
@Subscribe注解
EventBus 通过 @Subscribe 注解来标明, 某个函数能接收哪种类型的消息. 具体的使用代码如下所示. 在 DObserver 类中, 通过 @Subscribe 注解了两个函数 f1(), f2().
public DObserver {
//...省略其他属性和方法...
@Subscribe
public void f1(PMsg event) { //...
}
@Subscribe
public void f2(QMsg event) { //...
}
}
2
3
4
5
6
7
8
9
10
当通过 register() 函数将 DObserver 类对象注册到 EventBus 的时候, EventBus 会根据 @Subscribe 注解找到 f1() 和 f2(), 并且将两个函数能接收的消息类型记录下来(PMsg->f1, QMsg->f2) . 当通过 post() 函数发送消息(比如 QMsg 消息)的时候, EventBus 会通过之前的记录(QMsg->f2), 调用相应的函数(f2).
# 3.手把手实现一个EventBus框架
Guava EventBus 的功能已经讲清楚了, 总体上来说, 还是比较简单的. 接下来就重复造轮子, "山寨"一个 EventBus 出来.
重点来看, EventBus 中两个核心函数 register() 和 post() 的实现原理. 弄懂了它们, 基本上就弄懂了整个 EventBus 框架. 下面两张图是这两个函数的实现原理图.


从图中可以看出, 最关键的一个数据结构是 Observer 注册表, 记录了消息类型和可接收消息函数的对应关系. 当调用 register() 函数注册观察者的时候, EventBus 通过解析 @Subscribe 注解, 生成 Observer 注册表. 当调用 post() 函数发送消息的时候, EventBus 通过注册表找到相应的可接收消息的函数, 然后通过 Java 的反射语法来动态地创建对象, 执行函数. 对于同步阻塞模式, EventBus 在一个线程内依次执行相应的函数. 对于异步非阻塞模式, EventBus 通过一个线程池来执行相应的函数.
弄懂了原理, 实现起来就简单多了. 整个小框架的代码实现包括 5 个类: EventBus, AsyncEventBus, Subscribe, ObserverAction, ObserverRegistry. 接下来依次来看下这 5 个类.
Subscribe
Subscribe 是一个注解, 用于标明观察者中的哪个函数可以接收消息.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Beta
public @interface Subscribe {}
2
3
4
ObserverAction
ObserverAction 类用来表示 @Subscribe 注解的方法, 其中 target 表示观察者类, method 表示方法. 它主要用在 ObserverRegistry 观察者注册表中.
public class ObserverAction {
private Object target;
private Method method;
public ObserverAction(Object target, Method method) {
this.target = Preconditions.checkNotNull(target);
this.method = method;
this.method.setAccessible(true);
}
public void execute(Object event) { // event是method方法的参数
try {
method.invoke(target, event);
} catch (InvocationTargetException | IllegalAccessException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObserverRegistry
ObserverRegistry 类就是前面讲到的 Observer 注册表, 是最复杂的一个类, 框架中几乎所有的核心逻辑都在这个类中. 这个类大量使用了 Java 的反射语法, 不过代码整体来说都不难理解, 其中一个比较有技巧的地方是 CopyOnWriteArraySet 的使用.
CopyOnWriteArraySet, 顾名思义, 在写入数据的时候, 会创建一个新的 set, 并且将原始数据 clone 到新的 set 中, 在新的 set 中写入数据完成之后, 再用新的 set 替换老的 set. 这样就能保证在写入数据的时候, 不影响数据的读取操作, 以此来解决读写并发问题. 除此之外, CopyOnWriteSet 还通过加锁的方式, 避免了并发写冲突. 具体的作用可以去查看一下 CopyOnWriteSet 类的源码, 一目了然.
public class ObserverRegistry {
private ConcurrentMap<Class<?>, CopyOnWriteArraySet<ObserverAction>> registry = new ConcurrentHashMap<>();
public void register(Object observer) {
Map<Class<?>, Collection<ObserverAction>> observerActions = findAllObserverActions(observer);
for (Map.Entry<Class<?>, Collection<ObserverAction>> entry : observerActions.entrySet()) {
Class<?> eventType = entry.getKey();
Collection<ObserverAction> eventActions = entry.getValue();
CopyOnWriteArraySet<ObserverAction> registeredEventActions = registry.get(eventType);
if (registeredEventActions == null) {
registry.putIfAbsent(eventType, new CopyOnWriteArraySet<>());
registeredEventActions = registry.get(eventType);
}
registeredEventActions.addAll(eventActions);
}
}
public List<ObserverAction> getMatchedObserverActions(Object event) {
List<ObserverAction> matchedObservers = new ArrayList<>();
Class<?> postedEventType = event.getClass();
for (Map.Entry<Class<?>, CopyOnWriteArraySet<ObserverAction>> entry : registry.entrySet()) {
Class<?> eventType = entry.getKey();
Collection<ObserverAction> eventActions = entry.getValue();
if (postedEventType.isAssignableFrom(eventType)) {
matchedObservers.addAll(eventActions);
}
}
return matchedObservers;
}
private Map<Class<?>, Collection<ObserverAction>> findAllObserverActions(Object observer) {
Map<Class<?>, Collection<ObserverAction>> observerActions = new HashMap<>();
Class<?> clazz = observer.getClass();
for (Method method : getAnnotatedMethods(clazz)) {
Class<?>[] parameterTypes = method.getParameterTypes();
Class<?> eventType = parameterTypes[0];
if (!observerActions.containsKey(eventType)) {
observerActions.put(eventType, new ArrayList<>());
}
observerActions.get(eventType).add(new ObserverAction(observer, method));
}
return observerActions;
}
private List<Method> getAnnotatedMethods(Class<?> clazz) {
List<Method> annotatedMethods = new ArrayList<>();
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(Subscribe.class)) {
Class<?>[] parameterTypes = method.getParameterTypes();
Preconditions.checkArgument(parameterTypes.length == 1,
"Method %s has @Subscribe annotation but has %s parameters." + "Subscriber methods must have exactly 1 parameter.",
method, parameterTypes.length);
annotatedMethods.add(method);
}
}
return annotatedMethods;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
EventBusEventBus
实现的是阻塞同步的观察者模式. 看代码你可能会有些疑问, 这明明就用到了线程池 Executor 啊. 实际上, MoreExecutors.directExecutor() 是 Google Guava 提供的工具类, 看似是多线程, 实际上是单线程. 之所以要这么实现, 主要还是为了跟 AsyncEventBus 统一代码逻辑, 做到代码复用.
public class EventBus {
private Executor executor;
private ObserverRegistry registry = new ObserverRegistry();
public EventBus() {
this(MoreExecutors.directExecutor());
}
protected EventBus(Executor executor) {
this.executor = executor;
}
public void register(Object object) {
registry.register(object);
}
public void post(Object event) {
List<ObserverAction> observerActions = registry.getMatchedObserverActions(event);
for (ObserverAction observerAction : observerActions) {
executor.execute(new Runnable() {
@Override
public void run() {
observerAction.execute(event);
}
});
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
AsyncEventBus
有了 EventBus, AsyncEventBus 的实现就非常简单了. 为了实现异步非阻塞的观察者模式, 它就不能再继续使用 MoreExecutors.directExecutor() 了, 而是需要在构造函数中, 由调用者注入线程池.
public class AsyncEventBus extends EventBus {
public AsyncEventBus(Executor executor) {
super(executor);
}
}
2
3
4
5
至此, 我们用了不到 200 行代码, 就实现了一个还算凑活能用的 EventBus, 从功能上来讲, 它跟 Google Guava EventBus 几乎一样. 不过如果去查看Google Guava EventBus 的源码, 你会发现, 在实现细节方面, 相比现在的实现, 它其实做了很多优化, 比如优化了在注册表中查找消息可匹配函数的算法.
重点回顾
框架的作用有: 隐藏实现细节, 降低开发难度, 做到代码复用, 解耦业务与非业务代码, 让程序员聚焦业务开发. 针对异步非阻塞观察者模式, 也可以将它抽象成框架来达到这样的效果, 而这个框架就是这节课讲的 EventBus. EventBus 翻译为 "事件总线", 它提供了实现观察者模式的骨架代码. 可以基于此框架, 非常容易地在自己的业务场景中实现观察者模式, 不需要从零开始开发.
# 58-模板模式(上):剖析模板模式在JDK,Servlet,JUnit等中的应用
今天再学习另外一种行为型设计模式, 模板模式. 多次强调, 绝大部分设计模式的原理和实现, 都非常简单, 难的是掌握应用场景, 搞清楚能解决什么问题. 模板模式也不例外. 模板模式主要是用来解决复用和扩展两个问题. 今天会结合 Java Servlet, JUnit TestCase, Java InputStream, Java AbstractList 四个例子来具体讲解这两个作用.
# 1.模板模式的原理与实现
模板模式, 全称是模板方法设计模式, 英文是 Template Method Design Pattern. 在 GoF 的《设计模式》一书中, 它是这么定义的:
Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.
翻译成中文就是: 模板方法模式在一个方法中定义一个算法骨架, 并将某些步骤推迟到子类中实现. 模板方法模式可以让子类在不改变算法整体结构的情况下, 重新定义算法中的某些步骤.
这里的"算法", 可以理解为广义上的 "业务逻辑", 并不特指数据结构和算法中的 "算法". 这里的算法骨架就是 "模板", 包含算法骨架的方法就是 "模板方法", 这也是模板方法模式名字的由来.
原理很简单, 代码实现就更加简单, 一个示例代码如下所示. templateMethod() 函数定义为 final, 是为了避免子类重写它. method1() 和 method2() 定义为 abstract, 是为了强迫子类去实现. 不过, 这些都不是必须的, 在实际的项目开发中, 模板模式的代码实现比较灵活, 待会儿讲到应用场景的时候, 会有具体的体现.
public abstract class AbstractClass {
public final void templateMethod() {
//...
method1();
//...
method2();
//...
}
protected abstract void method1();
protected abstract void method2();
}
public class ConcreteClass1 extends AbstractClass {
@Override
protected void method1() {
//...
}
@Override
protected void method2() {
//...
}
}
public class ConcreteClass2 extends AbstractClass {
@Override
protected void method1() {
//...
}
@Override
protected void method2() {
//...
}
}
AbstractClass demo = ConcreteClass1();
demo.templateMethod();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 2.模板模式作用一:复用
开篇的时候讲到模板模式有两大作用: 复用和扩展. 先来看它的第一个作用: 复用.
模板模式把一个算法中不变的流程抽象到父类的模板方法 templateMethod() 中, 将可变的部分 method1(), method2() 留给子类 ContreteClass1 和 ContreteClass2 来实现. 所有的子类都可以复用父类中模板方法定义的流程代码. 通过两个小例子来更直观地体会一下.
# (1)Java InputStream
Java IO 类库中, 有很多类的设计用到了模板模式, 比如 InputStream, OutputStream, Reader, Writer. 拿 InputStream 来举例说明一下.
我把 InputStream 部分相关代码贴在了下面. 在代码中, read() 函数是一个模板方法, 定义了读取数据的整个流程, 并且暴露了一个可以由子类来定制的抽象方法. 不过这个方法也被命名为了 read(), 只是参数跟模板方法不同.
public abstract class InputStream implements Closeable {
//...省略其他代码...
public int read(byte b[], int off, int len) throws IOException {
if (b <mark> null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len </mark> 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
public abstract int read() throws IOException;
}
public class ByteArrayInputStream extends InputStream {
//...省略其他代码...
@Override
public synchronized int read() {
return (pos < count) ? (buf[pos++] & 0xff) : -1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# (2)Java AbstractList
在 Java AbstractList 类中, addAll() 函数可以看作模板方法, add() 是子类需要重写的方法, 尽管没有声明为 abstract 的, 但函数实现直接抛出了 UnsupportedOperationException 异常. 前提是, 如果子类不重写是不能使用的.
public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
2
3
4
5
6
7
8
9
10
11
12
13
# 3.模板模式作用二:扩展
模板模式的第二大作用的是扩展. 这里所说的扩展, 并不是指代码的扩展性, 而是指框架的扩展性, 有点类似之前讲到的控制反转. 基于这个作用, 模板模式常用在框架的开发中, 让框架用户可以在不修改框架源码的情况下, 定制化框架的功能. 这里通过 Junit TestCase, Java Servlet 两个例子来解释一下.
# (1)Java Servlet
对于 Java Web 项目开发来说, 常用的开发框架是 SpringMVC. 利用它, 只需要关注业务代码的编写, 底层的原理几乎不会涉及. 但是, 如果抛开这些高级框架来开发 Web 项目, 必然会用到 Servlet. 实际上, 使用比较底层的 Servlet 来开发 Web 项目也不难. 只需要定义一个继承 HttpServlet 的类, 并且重写其中的 doGet() 或 doPost() 方法, 来分别处理 get 和 post 请求. 具体的代码示例如下所示:
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().write("Hello World.");
}
}
2
3
4
5
6
7
8
9
10
11
除此之外, 还需要在配置文件 web.xml 中做如下配置. Tomcat, Jetty 等 Servlet 容器在启动的时候, 会自动加载这个配置文件中的 URL 和 Servlet 之间的映射关系.
<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.xzg.cd.HelloServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>HelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
2
3
4
5
6
7
8
9
当在浏览器中输入网址(比如, http://127.0.0.1:8080/hello) 的时候, Servlet 容器会接收到相应的请求, 并且根据 URL 和 Servlet 之间的映射关系, 找到相应的 Servlet(HelloServlet), 然后执行它的 service() 方法. service() 方法定义在父类 HttpServlet 中, 它会调用 doGet() 或 doPost() 方法, 然后输出数据("Hello world")到网页.
现在来看, HttpServlet 的 service() 函数长什么样子.
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException {
HttpServletRequest request;
HttpServletResponse response;
if (!(req instanceof HttpServletRequest &&
res instanceof HttpServletResponse)) {
throw new ServletException("non-HTTP request or response");
}
request = (HttpServletRequest) req;
response = (HttpServletResponse) res;
service(request, response);
}
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
long lastModified = getLastModified(req);
if (lastModified == -1) {
// servlet doesn't support if-modified-since, no reason
// to go through further expensive logic
doGet(req, resp);
} else {
long ifModifiedSince = req.getDateHeader(HEADER_IFMODSINCE);
if (ifModifiedSince < lastModified) {
// If the servlet mod time is later, call doGet()
// Round down to the nearest second for a proper compare
// A ifModifiedSince of -1 will always be less
maybeSetLastModified(resp, lastModified);
doGet(req, resp);
} else {
resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
}
}
} else if (method.equals(METHOD_HEAD)) {
long lastModified = getLastModified(req);
maybeSetLastModified(resp, lastModified);
doHead(req, resp);
} else if (method.equals(METHOD_POST)) {
doPost(req, resp);
} else if (method.equals(METHOD_PUT)) {
doPut(req, resp);
} else if (method.equals(METHOD_DELETE)) {
doDelete(req, resp);
} else if (method.equals(METHOD_OPTIONS)) {
doOptions(req, resp);
} else if (method.equals(METHOD_TRACE)) {
doTrace(req, resp);
} else {
String errMsg = lStrings.getString("http.method_not_implemented");
Object[] errArgs = new Object[1];
errArgs[0] = method;
errMsg = MessageFormat.format(errMsg, errArgs);
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
从上面的代码中可以看出, HttpServlet 的 service() 方法就是一个模板方法, 它实现了整个 HTTP 请求的执行流程, doGet(), doPost() 是模板中可以由子类来定制的部分. 实际上, 这就相当于 Servlet 框架提供了一个扩展点(doGet(), doPost() 方法), 让框架用户在不用修改 Servlet 框架源码的情况下, 将业务代码通过扩展点镶嵌到框架中执行.
# (2)JUnit TestCase
跟 Java Servlet 类似, JUnit 框架也通过模板模式提供了一些功能扩展点(setUp(), tearDown() 等), 让框架用户可以在这些扩展点上扩展功能.
在使用 JUnit 测试框架来编写单元测试的时候, 编写的测试类都要继承框架提供的 TestCase 类. 在 TestCase 类中, runBare() 函数是模板方法, 它定义了执行测试用例的整体流程: 先执行 setUp() 做些准备工作, 然后执行 runTest() 运行真正的测试代码, 最后执行 tearDown() 做扫尾工作.
TestCase 类的具体代码如下所示. 尽管 setUp(), tearDown() 并不是抽象函数, 还提供了默认的实现, 不强制子类去重新实现, 但这部分也是可以在子类中定制的, 所以也符合模板模式的定义.
public abstract class TestCase extends Assert implements Test {
public void runBare() throws Throwable {
Throwable exception = null;
setUp();
try {
runTest();
} catch (Throwable running) {
exception = running;
} finally {
try {
tearDown();
} catch (Throwable tearingDown) {
if (exception == null) exception = tearingDown;
}
}
if (exception != null) throw exception;
}
/**
* Sets up the fixture, for example, open a network connection.
* This method is called before a test is executed.
*/
protected void setUp() throws Exception {
}
/**
* Tears down the fixture, for example, close a network connection.
* This method is called after a test is executed.
*/
protected void tearDown() throws Exception {
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
重点回顾
模板方法模式在一个方法中定义一个算法骨架, 并将某些步骤推迟到子类中实现. 模板方法模式可以让子类在不改变算法整体结构的情况下, 重新定义算法中的某些步骤. 这里的算法骨架就是 "模板", 包含算法骨架的方法就是 "模板方法", 这也是模板方法模式名字的由来.
在模板模式经典的实现中, 模板方法定义为 final, 可以避免被子类重写. 需要子类重写的方法定义为 abstract, 可以强迫子类去实现. 不过, 在实际项目开发中, 模板模式的实现比较灵活, 以上两点都不是必须的.
模板模式有两大作用: 复用和扩展. 其中, 复用指的是, 所有的子类可以复用父类中提供的模板方法的代码. 扩展指的是, 框架通过模板模式提供功能扩展点, 让框架用户可以在不修改框架源码的情况下, 基于扩展点定制化框架的功能.
# 59-模板模式(下):模板模式与Callback回调函数有何区别和联系?
上一节学习了模板模式的原理, 实现和应用. 它常用在框架开发中, 通过提供功能扩展点, 让框架用户在不修改框架源码的情况下, 基于扩展点定制化框架的功能. 除此之外, 模板模式还可以起到代码复用的作用.
复用和扩展是模板模式的两大作用, 实际上, 还有另外一个技术概念, 也能起到跟模板模式相同的作用, 那就是回调(Callback). 今天就来看一下, 回调的原理, 实现和应用, 以及它跟模板模式的区别和联系.
# 1.回调的原理解析
相对于普通的函数调用来说, 回调是一种双向调用关系. A 类事先注册某个函数 F 到 B 类, A 类在调用 B 类的 P 函数的时候, B 类反过来调用 A 类注册给它的 F 函数. 这里的 F 函数就是 "回调函数". A 调用 B, B 反过来又调用 A, 这种调用机制就叫作 "回调".
A 类如何将回调函数传递给 B 类呢? 不同的编程语言, 有不同的实现方法. C 语言可以使用函数指针, Java 则需要使用包裹了回调函数的类对象, 简称为回调对象. 这里用 Java 语言举例说明一下. 代码如下所示:
public interface ICallback {
void methodToCallback();
}
public class BClass {
public void process(ICallback callback) {
//...
callback.methodToCallback();
//...
}
}
public class AClass {
public static void main(String[] args) {
BClass b = new BClass();
b.process(new ICallback() { // 回调对象
@Override
public void methodToCallback() {
System.out.println("Call back me.");
}
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
上面就是 Java 语言中回调的典型代码实现. 从代码实现中, 可以看出, 回调跟模板模式一样, 也具有复用和扩展的功能. 除了回调函数之外, BClass 类的 process() 函数中的逻辑都可以复用. 如果 ICallback, BClass 类是框架代码, AClass 是使用框架的客户端代码, 可以通过 ICallback 定制 process() 函数, 也就是说, 框架因此具有了扩展的能力.
实际上, 回调不仅可以应用在代码设计上, 在更高层次的架构设计上也比较常用. 比如通过三方支付系统来实现支付功能, 用户在发起支付请求之后, 一般不会一直阻塞到支付结果返回, 而是注册回调接口(类似回调函数, 一般是一个回调用的 URL)给三方支付系统, 等三方支付系统执行完成之后, 将结果通过回调接口返回给用户.
回调可以分为同步回调和异步回调(或者延迟回调). 同步回调指在函数返回之前执行回调函数; 异步回调指的是在函数返回之后执行回调函数. 上面的代码实际上是同步回调的实现方式, 在 process() 函数返回之前, 执行完回调函数 methodToCallback(). 而上面支付的例子是异步回调的实现方式, 发起支付之后不需要等待回调接口被调用就直接返回. 从应用场景上来看, 同步回调看起来更像模板模式, 异步回调看起来更像观察者模式.
# 2.应用举例一:JdbcTemplate
Spring 提供了很多 Template 类, 比如, JdbcTemplate, RedisTemplate, RestTemplate. 尽管都叫作 xxxTemplate, 但它们并非基于模板模式来实现的, 而是基于回调来实现的, 确切地说应该是同步回调. 而同步回调从应用场景上很像模板模式, 所以在命名上, 这些类使用 Template(模板)这个单词作为后缀.
这些 Template 类的设计思路都很相近, 所以只拿其中的 JdbcTemplate 来举例分析一下. Java 提供了 JDBC 类库来封装不同类型的数据库操作. 不过直接使用 JDBC 来编写操作数据库的代码, 还是有点复杂的. 比如, 下面这段是使用 JDBC 来查询用户信息的代码.
public class JdbcDemo {
public User queryUser(long id) {
Connection conn = null;
Statement stmt = null;
try {
// 1.加载驱动
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/demo", "xzg", "xzg");
// 2.创建statement类对象, 用来执行SQL语句
stmt = conn.createStatement();
// 3.ResultSet类, 用来存放获取的结果集
String sql = "select * from user where id=" + id;
ResultSet resultSet = stmt.executeQuery(sql);
String eid = null, ename = null, price = null;
while (resultSet.next()) {
User user = new User();
user.setId(resultSet.getLong("id"));
user.setName(resultSet.getString("name"));
user.setTelephone(resultSet.getString("telephone"));
return user;
}
} catch (ClassNotFoundException e) {
// TODO: log...
} catch (SQLException e) {
// TODO: log...
} finally {
if (conn != null)
try {
conn.close();
} catch (SQLException e) {
// TODO: log...
}
if (stmt != null)
try {
stmt.close();
} catch (SQLException e) {
// TODO: log...
}
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
queryUser() 函数包含很多流程性质的代码, 跟业务无关, 比如, 加载驱动, 创建数据库连接, 创建 statement, 关闭连接, 关闭 statement, 处理异常. 针对不同的 SQL 执行请求, 这些流程性质的代码是相同的, 可以复用的, 不需要每次都重新敲一遍.
针对这个问题, Spring 提供了 JdbcTemplate, 对 JDBC 进一步封装, 来简化数据库编程. 使用 JdbcTemplate 查询用户信息, 只需要编写跟这个业务有关的代码, 其中包括, 查询用户的 SQL 语句, 查询结果与 User 对象之间的映射关系. 其他流程性质的代码都封装在了 JdbcTemplate 类中, 不需要每次都重新编写. 用 JdbcTemplate 重写了上面的例子, 代码简单了很多, 如下所示:
public class JdbcTemplateDemo {
private JdbcTemplate jdbcTemplate;
public User queryUser(long id) {
String sql = "select * from user where id="+id;
return jdbcTemplate.query(sql, new UserRowMapper()).get(0);
}
class UserRowMapper implements RowMapper<User> {
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setTelephone(rs.getString("telephone"));
return user;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
那 JdbcTemplate 底层具体是如何实现的呢? 来看一下它的源码. 因为 JdbcTemplate 代码比较多, 只摘抄了部分相关代码, 贴到了下面. 其中, JdbcTemplate 通过回调的机制, 将不变的执行流程抽离出来, 放到模板方法 execute() 中, 将可变的部分设计成回调 StatementCallback, 由用户来定制. query() 函数是对 execute() 函数的二次封装, 让接口用起来更加方便.
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper) throws DataAccessException {
return query(sql, new RowMapperResultSetExtractor<T>(rowMapper));
}
@Override
public <T> T query(final String sql, final ResultSetExtractor<T> rse) throws DataAccessException {
Assert.notNull(sql, "SQL must not be null");
Assert.notNull(rse, "ResultSetExtractor must not be null");
if (logger.isDebugEnabled()) {
logger.debug("Executing SQL query [" + sql + "]");
}
class QueryStatementCallback implements StatementCallback<T>, SqlProvider {
@Override
public T doInStatement(Statement stmt) throws SQLException {
ResultSet rs = null;
try {
rs = stmt.executeQuery(sql);
ResultSet rsToUse = rs;
if (nativeJdbcExtractor != null) {
rsToUse = nativeJdbcExtractor.getNativeResultSet(rs);
}
return rse.extractData(rsToUse);
} finally {
JdbcUtils.closeResultSet(rs);
}
}
@Override
public String getSql() {
return sql;
}
}
return execute(new QueryStatementCallback());
}
@Override
public <T> T execute(StatementCallback<T> action) throws DataAccessException {
Assert.notNull(action, "Callback object must not be null");
Connection con = DataSourceUtils.getConnection(getDataSource());
Statement stmt = null;
try {
Connection conToUse = con;
if (this.nativeJdbcExtractor != null &&
this.nativeJdbcExtractor.isNativeConnectionNecessaryForNativeStatements()) {
conToUse = this.nativeJdbcExtractor.getNativeConnection(con);
}
stmt = conToUse.createStatement();
applyStatementSettings(stmt);
Statement stmtToUse = stmt;
if (this.nativeJdbcExtractor != null) {
stmtToUse = this.nativeJdbcExtractor.getNativeStatement(stmt);
}
T result = action.doInStatement(stmtToUse);
handleWarnings(stmt);
return result;
} catch (SQLException ex) {
// Release Connection early, to avoid potential connection pool deadlock
// in the case when the exception translator hasn't been initialized yet.
JdbcUtils.closeStatement(stmt);
stmt = null;
DataSourceUtils.releaseConnection(con, getDataSource());
con = null;
throw getExceptionTranslator().translate("StatementCallback", getSql(action), ex);
} finally {
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, getDataSource());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 3.应用举例二:setClickListener()
在客户端开发中, 经常给控件注册事件监听器, 比如下面这段代码, 就是在 Android 应用开发中, 给 Button 控件的点击事件注册监听器.
Button button = (Button)findViewById(R.id.button);
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
System.out.println("I am clicked.");
}
});
2
3
4
5
6
7
从代码结构上来看, 事件监听器很像回调, 即传递一个包含回调函数(onClick())的对象给另一个函数. 从应用场景上来看, 它又很像观察者模式, 即事先注册观察者(OnClickListener), 当用户点击按钮的时候, 发送点击事件给观察者, 并且执行相应的 onClick() 函数.
前面讲到, 回调分为同步回调和异步回调. 这里的回调算是异步回调, 往 setOnClickListener() 函数中注册好回调函数之后, 并不需要等待回调函数执行. 这也印证了前面讲的, 异步回调比较像观察者模式.
# 4.应用举例三:addShutdownHook()
Hook 可以翻译成 "钩子", 那它跟 Callback 有什么区别呢?
网上有人认为 Hook 就是 Callback, 两者说的是一回事儿, 只是表达不同而已. 而有人觉得 Hook 是 Callback 的一种应用. Callback 更侧重语法机制的描述, Hook 更加侧重应用场景的描述. 我个人比较认可后面一种说法. 不过这个也不重要, 只需要见了代码能认识, 遇到场景会用就可以了.
Hook 比较经典的应用场景是 Tomcat 和 JVM 的 shutdown hook. 接下来, 拿 JVM 来举例说明一下. JVM 提供了 Runtime.addShutdownHook(Thread hook) 方法, 可以注册一个 JVM 关闭的 Hook. 当应用程序关闭的时候, JVM 会自动调用 Hook 代码. 代码示例如下所示:
public class ShutdownHookDemo {
private static class ShutdownHook extends Thread {
public void run() {
System.out.println("I am called during shutting down.");
}
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new ShutdownHook());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
再来看 addShutdownHook() 的代码实现, 如下所示. 这里只给出了部分相关代码.
public class Runtime {
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}
}
class ApplicationShutdownHooks {
/* The set of registered hooks */
private static IdentityHashMap<Thread, Thread> hooks;
static {
hooks = new IdentityHashMap<>();
} catch (IllegalStateException e) {
hooks = null;
}
}
static synchronized void add(Thread hook) {
if(hooks == null)
throw new IllegalStateException("Shutdown in progress");
if (hook.isAlive())
throw new IllegalArgumentException("Hook already running");
if (hooks.containsKey(hook))
throw new IllegalArgumentException("Hook previously registered");
hooks.put(hook, hook);
}
static void runHooks() {
Collection<Thread> threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}
for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
while (true) {
try {
hook.join();
break;
} catch (InterruptedException ignored) {
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
从代码中可以发现, 有关 Hook 的逻辑都被封装到 ApplicationShutdownHooks 类中了. 当应用程序关闭的时候, JVM 会调用这个类的 runHooks() 方法, 创建多个线程, 并发地执行多个 Hook. 在注册完 Hook 之后, 并不需要等待 Hook 执行完成, 所以这也算是一种异步回调.
# 5.模板模式VS回调
回调的原理, 实现和应用到此就都讲完了. 接下来从应用场景和代码实现两个角度, 来对比一下模板模式和回调.
从应用场景上来看, 同步回调跟模板模式几乎一致. 它们都是在一个大的算法骨架中, 自由替换其中的某个步骤, 起到代码复用和扩展的目的. 而异步回调跟模板模式有较大差别, 更像是观察者模式.
从代码实现上来看, 回调和模板模式完全不同. 回调基于组合关系来实现, 把一个对象传递给另一个对象, 是一种对象之间的关系; 模板模式基于继承关系来实现, 子类重写父类的抽象方法, 是一种类之间的关系.
前面也讲到, 组合优于继承. 实际上, 这里也不例外. 在代码实现上, 回调相对于模板模式会更加灵活, 主要体现在下面几点.
- 像 Java 这种只支持单继承的语言, 基于模板模式编写的子类, 已经继承了一个父类, 不再具有继承的能力.
- 回调可以使用匿名类来创建回调对象, 可以不用事先定义类; 而模板模式针对不同的实现都要定义不同的子类.
- 如果某个类中定义了多个模板方法, 每个方法都有对应的抽象方法, 那即便只用到其中的一个模板方法, 子类也必须实现所有的抽象方法. 而回调就更加灵活, 只需要往用到的模板方法中注入回调对象即可.
重点回顾
今天重点介绍了回调. 它跟模板模式具有相同的作用: 代码复用和扩展. 在一些框架, 类库, 组件等的设计中经常会用到.
相对于普通的函数调用, 回调是一种双向调用关系. A 类事先注册某个函数 F 到 B 类, A 类在调用 B 类的 P 函数的时候, B 类反过来调用 A 类注册给它的 F 函数. 这里的 F 函数就是 "回调函数". A 调用 B, B 反过来又调用 A, 这种调用机制就叫作 "回调".
回调可以细分为同步回调和异步回调. 从应用场景上来看, 同步回调看起来更像模板模式, 异步回调看起来更像观察者模式. 回调跟模板模式的区别, 更多的是在代码实现上, 而非应用场景上. 回调基于组合关系来实现, 模板模式基于继承关系来实现, 回调比模板模式更加灵活.
# 60-策略模式(上):如何避免冗长的if-else/switch分支判断代码?
今天开始学习另外一种行为型模式, 策略模式. 在实际的项目开发中, 这个模式也比较常用. 最常见的应用场景是, 利用它来避免冗长的 if-else 或 switch 分支判断. 不过它的作用还不止如此. 它也可以像模板模式那样, 提供框架的扩展点等等.
今天讲解策略模式的原理和实现, 以及如何用它来避免分支判断逻辑. 下一节会通过一个具体的例子, 来详细讲解策略模式的应用场景以及真正的设计意图.
# 1.策略模式的原理与实现
策略模式, 英文全称是 Strategy Design Pattern. 在 GoF 的《设计模式》一书中, 它是这样定义的:
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
翻译成中文就是: 定义一族算法类, 将每个算法分别封装起来, 让它们可以互相替换. 策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码) .
工厂模式是解耦对象的创建和使用, 观察者模式是解耦观察者和被观察者. 策略模式跟两者类似, 也能起到解耦的作用, 不过, 它解耦的是策略的定义, 创建, 使用这三部分. 接下来就详细讲讲一个完整的策略模式应该包含的这三个部分.
# (1)策略的定义
策略类的定义比较简单, 包含一个策略接口和一组实现这个接口的策略类. 因为所有的策略类都实现相同的接口, 所以, 客户端代码基于接口而非实现编程, 可以灵活地替换不同的策略. 示例代码如下所示:
public interface Strategy {
void algorithmInterface();
}
public class ConcreteStrategyA implements Strategy {
@Override
public void algorithmInterface() {
// 具体的算法...
}
}
public class ConcreteStrategyB implements Strategy {
@Override
public void algorithmInterface() {
// 具体的算法...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# (2)策略的创建
因为策略模式会包含一组策略, 在使用它们的时候, 一般会通过类型(type)来判断创建哪个策略来使用. 为了封装创建逻辑, 需要对客户端代码屏蔽创建细节. 可以把根据 type 创建策略的逻辑抽离出来, 放到工厂类中. 示例代码如下所示:
public class StrategyFactory {
private static final Map<String, Strategy> strategies = new HashMap<>();
static {
strategies.put("A", new ConcreteStrategyA());
strategies.put("B", new ConcreteStrategyB());
}
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return strategies.get(type);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
一般来讲, 如果策略类是无状态的, 不包含成员变量, 只是纯粹的算法实现, 这样的策略对象是可以被共享使用的, 不需要在每次调用 getStrategy() 的时候, 都创建一个新的策略对象. 针对这种情况, 可以使用上面这种工厂类的实现方式, 事先创建好每个策略对象, 缓存到工厂类中, 用的时候直接返回.
相反, 如果策略类是有状态的, 根据业务场景的需要, 希望每次从工厂方法中, 获得的都是新创建的策略对象, 而不是缓存好可共享的策略对象, 那就需要按照如下方式来实现策略工厂类.
public class StrategyFactory {
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
if (type.equals("A")) {
return new ConcreteStrategyA();
} else if (type.equals("B")) {
return new ConcreteStrategyB();
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# (3)策略的使用
刚刚讲了策略的定义和创建, 现在再来看一下, 策略的使用.
策略模式包含一组可选策略, 客户端代码一般如何确定使用哪个策略呢? 最常见的是运行时动态确定使用哪种策略, 这也是策略模式最典型的应用场景.
这里的 "运行时动态" 指的是, 事先并不知道会使用哪个策略, 而是在程序运行期间, 根据配置, 用户输入, 计算结果等这些不确定因素, 动态决定使用哪种策略. 下面通过一个例子来解释一下.
// 策略接口: EvictionStrategy
// 策略类: LruEvictionStrategy, FifoEvictionStrategy, LfuEvictionStrategy...
// 策略工厂: EvictionStrategyFactory
public class UserCache {
private Map<String, User> cacheData = new HashMap<>();
private EvictionStrategy eviction;
public UserCache(EvictionStrategy eviction) {
this.eviction = eviction;
}
//...
}
// 运行时动态确定, 根据配置文件的配置决定使用哪种策略
public class Application {
public static void main(String[] args) throws Exception {
EvictionStrategy evictionStrategy = null;
Properties props = new Properties();
props.load(new FileInputStream("./config.properties"));
String type = props.getProperty("eviction_type");
evictionStrategy = EvictionStrategyFactory.getEvictionStrategy(type);
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
// 非运行时动态确定, 在代码中指定使用哪种策略
public class Application {
public static void main(String[] args) {
//...
EvictionStrategy evictionStrategy = new LruEvictionStrategy();
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
从上面的代码也可以看出, "非运行时动态确定", 也就是第二个 Application 中的使用方式, 并不能发挥策略模式的优势. 在这种应用场景下, 策略模式实际上退化成了 "面向对象的多态特性" 或 "基于接口而非实现编程原则".
# 2.如何利用策略模式避免分支判断?
实际上, 能够移除分支判断逻辑的模式不仅仅有策略模式, 后面要讲的状态模式也可以. 对于使用哪种模式, 具体还要看应用场景来定. 策略模式适用于根据不同类型待动态, 决定使用哪种策略这样一种应用场景.
先通过一个例子来看下, if-else 或 switch-case 分支判断逻辑是如何产生的. 具体的代码如下所示. 在这个例子中, 没有使用策略模式, 而是将策略的定义, 创建, 使用直接耦合在一起.
public class OrderService {
public double discount(Order order) {
double discount = 0.0;
OrderType type = order.getType();
if (type.equals(OrderType.NORMAL)) { // 普通订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.GROUPON)) { // 团购订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.PROMOTION)) { // 促销订单
//...省略折扣计算算法代码
}
return discount;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如何来移除掉分支判断逻辑呢? 那策略模式就派上用场了. 使用策略模式对上面的代码重构, 将不同类型订单的打折策略设计成策略类, 并由工厂类来负责创建策略对象. 具体的代码如下所示:
// 策略的定义
public interface DiscountStrategy {
double calDiscount(Order order);
}
// 省略NormalDiscountStrategy, GrouponDiscountStrategy, PromotionDiscountStrategy类代码...
// 策略的创建
public class DiscountStrategyFactory {
private static final Map<OrderType, DiscountStrategy> strategies = new HashMap<>();
static {
strategies.put(OrderType.NORMAL, new NormalDiscountStrategy());
strategies.put(OrderType.GROUPON, new GrouponDiscountStrategy());
strategies.put(OrderType.PROMOTION, new PromotionDiscountStrategy());
}
public static DiscountStrategy getDiscountStrategy(OrderType type) {
return strategies.get(type);
}
}
// 策略的使用
public class OrderService {
public double discount(Order order) {
OrderType type = order.getType();
DiscountStrategy discountStrategy = DiscountStrategyFactory.getDiscountStrategy(type);
return discountStrategy.calDiscount(order);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
重构之后的代码就没有了 if-else 分支判断语句了. 实际上, 这得益于策略工厂类. 在工厂类中, 用 Map 来缓存策略, 根据 type 直接从 Map 中获取对应的策略, 从而避免 if-else 分支判断逻辑. 等后面讲到使用状态模式来避免分支判断逻辑的时候, 你会发现它们使用的是同样的套路. 本质上都是借助 "查表法", 根据 type 查表(代码中的 strategies 就是表)替代根据 type 分支判断.
但如果业务场景需要每次都创建不同的策略对象, 就要用另外一种工厂类的实现方式了. 具体的代码如下所示:
public class DiscountStrategyFactory {
public static DiscountStrategy getDiscountStrategy(OrderType type) {
if (type == null) {
throw new IllegalArgumentException("Type should not be null.");
}
if (type.equals(OrderType.NORMAL)) {
return new NormalDiscountStrategy();
} else if (type.equals(OrderType.GROUPON)) {
return new GrouponDiscountStrategy();
} else if (type.equals(OrderType.PROMOTION)) {
return new PromotionDiscountStrategy();
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这种实现方式相当于把原来的 if-else 分支逻辑, 从 OrderService 类中转移到了工厂类中, 实际上并没有真正将它移除. 关于这个问题如何解决, 下一节再讲解.
重点回顾
策略模式定义一族算法类, 将每个算法分别封装起来, 让它们可以互相替换. 策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码).
策略模式用来解耦策略的定义, 创建, 使用. 实际上, 一个完整的策略模式就是由这三个部分组成的.
- 策略类的定义比较简单, 包含一个策略接口和一组实现这个接口的策略类.
- 策略的创建由工厂类来完成, 封装策略创建的细节.
- 策略模式包含一组策略可选, 客户端代码如何选择使用哪个策略, 有两种确定方法: 编译时静态确定和运行时动态确定. 其中, "运行时动态确定"才是策略模式最典型的应用场景.
除此之外, 还可以通过策略模式来移除 if-else 分支判断. 实际上这得益于策略工厂类, 更本质上点讲, 是借助 "查表法", 根据 type 查表替代根据 type 分支判断.
# 61-策略模式(下):如何实现一个支持给不同大小文件排序的小程序?
上一节主要介绍了策略模式的原理和实现, 以及如何利用策略模式来移除 if-else 或者 switch-case 分支判断逻辑. 今天结合 "给文件排序" 的例子, 来详细讲一讲策略模式的设计意图和应用场景.
除此之外, 今天还会通过一步一步地分析, 重构, 展示一个设计模式是如何 "创造" 出来的. 通过今天的学习, 你会发现, 设计原则和思想其实比设计模式更加普适和重要, 掌握了代码的设计原则和思想, 甚至可以自己创造出来新的设计模式.
# 1.问题与解决思路
假设有这样一个需求, 希望写一个小程序, 实现对一个文件进行排序的功能. 文件中只包含整型数, 并且相邻的数字通过逗号来区隔. 如果由你来编写这样一个小程序, 你会如何来实现呢?
你可能会说, 这不是很简单嘛, 只需要将文件中的内容读取出来, 并且通过逗号分割成一个一个的数字, 放到内存数组中, 然后编写某种排序算法(比如快排), 或者直接使用编程语言提供的排序函数, 对数组进行排序, 最后再将数组中的数据写入文件就可以了.
但如果文件很大呢? 比如有 10GB 大小, 因为内存有限(比如只有 8GB 大小), 没办法一次性加载文件中的所有数据到内存中, 这个时候就要利用外部排序算法.
如果文件更大, 比如有 100GB 大小, 为了利用 CPU 多核的优势, 可以在外部排序的基础之上进行优化, 加入多线程并发排序的功能, 这就有点类似"单机版"的 MapReduce. 如果文件非常大, 比如有 1TB 大小, 即便是单机多线程排序, 这也算很慢了. 这个时候可以使用真正的 MapReduce 框架, 利用多机的处理能力, 提高排序的效率.
# 2.代码实现与分析
解决思路讲完了. 接下来看一下, 如何将解决思路翻译成代码实现.
先用最简单直接的方式实现将它实现出来. 因为是在讲设计模式, 不是讲算法, 所以下面的代码实现只给出了跟设计模式相关的骨架代码, 并没有给出每种排序算法的具体代码实现.
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
if (fileSize < 6 * GB) { // [0, 6GB)
quickSort(filePath);
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
externalSort(filePath);
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
concurrentExternalSort(filePath);
} else { // [100GB, ~)
mapreduceSort(filePath);
}
}
private void quickSort(String filePath) {
// 快速排序
}
private void externalSort(String filePath) {
// 外部排序
}
private void concurrentExternalSort(String filePath) {
// 多线程外部排序
}
private void mapreduceSort(String filePath) {
// 利用MapReduce多机排序
}
}
public class SortingTool {
public static void main(String[] args) {
Sorter sorter = new Sorter();
sorter.sortFile(args[0]);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
在 "编码规范 "那一部分讲过, 函数的行数不能过多, 最好不要超过一屏的大小. 所以为了避免 sortFile() 函数过长, 把每种排序算法从 sortFile() 函数中抽离出来, 拆分成 4 个独立的排序函数.
如果只是开发一个简单的工具, 那上面的代码实现就足够了. 毕竟代码不多, 后续修改, 扩展的需求也不多, 怎么写都不会导致代码不可维护. 但如果是在开发一个大型项目, 排序文件只是其中的一个功能模块, 那就要在代码设计, 代码质量上下点儿功夫了. 只有每个小的功能模块都写好, 整个项目的代码才能不差.
在刚刚的代码中, 并没有给出每种排序算法的代码实现. 实际上, 如果自己实现一下的话, 你会发现, 每种排序算法的实现逻辑都比较复杂, 代码行数都比较多. 所有排序算法的代码实现都堆在 Sorter 一个类中, 这就会导致这个类的代码很多.
# 3.代码优化与重构
只要掌握了之前讲过的设计原则和思想, 针对上面的问题, 即便我们想不到该用什么设计模式来重构, 也应该能知道该如何解决, 那就是将 Sorter 类中的某些代码拆分出来, 独立成职责更加单一的小类. 实际上, 拆分是应对类或者函数代码过多, 应对代码复杂性的一个常用手段. 按照这个解决思路, 我们对代码进行重构. 重构之后的代码如下所示:
public interface ISortAlg {
void sort(String filePath);
}
public class QuickSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ConcurrentExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class MapReduceSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize < 6 * GB) { // [0, 6GB)
sortAlg = new QuickSort();
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
sortAlg = new ExternalSort();
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
sortAlg = new ConcurrentExternalSort();
} else { // [100GB, ~)
sortAlg = new MapReduceSort();
}
sortAlg.sort(filePath);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
经过拆分之后, 每个类的代码都不会太多, 每个类的逻辑都不会太复杂, 代码的可读性, 可维护性提高了. 除此之外, 将排序算法设计成独立的类, 跟具体的业务逻辑(代码中的 if-else 那部分逻辑)解耦, 也让排序算法能够复用. 这一步实际上就是策略模式的第一步, 也就是将策略的定义分离出来.
实际上, 上面的代码还可以继续优化. 每种排序类都是无状态的, 没必要在每次使用的时候, 都重新创建一个新的对象. 所以可以使用工厂模式对对象的创建进行封装. 按照这个思路, 对代码进行重构. 重构之后的代码如下所示:
public class SortAlgFactory {
private static final Map<String, ISortAlg> algs = new HashMap<>();
static {
algs.put("QuickSort", new QuickSort());
algs.put("ExternalSort", new ExternalSort());
algs.put("ConcurrentExternalSort", new ConcurrentExternalSort());
algs.put("MapReduceSort", new MapReduceSort());
}
public static ISortAlg getSortAlg(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return algs.get(type);
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize < 6 * GB) { // [0, 6GB)
sortAlg = SortAlgFactory.getSortAlg("QuickSort");
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
sortAlg = SortAlgFactory.getSortAlg("ExternalSort");
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
sortAlg = SortAlgFactory.getSortAlg("ConcurrentExternalSort");
} else { // [100GB, ~)
sortAlg = SortAlgFactory.getSortAlg("MapReduceSort");
}
sortAlg.sort(filePath);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
经过上面两次重构之后, 现在的代码实际上已经符合策略模式的代码结构了. 通过策略模式将策略的定义, 创建, 使用解耦, 让每一部分都不至于太复杂. 不过, Sorter 类中的 sortFile() 函数还是有一堆 if-else 逻辑. 这里的 if-else 逻辑分支不多, 也不复杂, 这样写完全没问题. 但如果特别想将 if-else 分支判断移除掉, 那也是有办法的. 下面直接给出代码, 你一看就能明白. 实际上, 这也是基于查表法来解决的, 其中的 "algs" 就是 "表".
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
private static final List<AlgRange> algs = new ArrayList<>();
static {
algs.add(new AlgRange(0, 6*GB, SortAlgFactory.getSortAlg("QuickSort")));algs.add(new AlgRange(6*GB, 10*GB, SortAlgFactory.getSortAlg("ExternalSort")));algs.add(new AlgRange(10*GB, 100*GB, SortAlgFactory.getSortAlg("ConcurrentExternalSort")));algs.add(new AlgRange(100*GB, Long.MAX_VALUE, SortAlgFactory.getSortAlg("MapReduceSort")));
}
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg = null;
for (AlgRange algRange : algs) {
if (algRange.inRange(fileSize)) {
sortAlg = algRange.getAlg();
break;
}
}
sortAlg.sort(filePath);
}
private static class AlgRange {
private long start;
private long end;
private ISortAlg alg;
public AlgRange(long start, long end, ISortAlg alg) {
this.start = start;
this.end = end;
this.alg = alg;
}
public ISortAlg getAlg() {
return alg;
}
public boolean inRange(long size) {
return size >= start && size < end;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
现在的代码实现就更加优美了. 把可变的部分隔离到了策略工厂类和 Sorter 类中的静态代码段中. 当要添加一个新的排序算法时, 只需要修改策略工厂类和 Sort 类中的静态代码段, 其他代码都不需要修改, 这样就将代码改动最小化, 集中化了.
你可能会说, 即便这样, 当添加新的排序算法的时候, 还是需要修改代码, 并不完全符合开闭原则. 有什么办法能完全满足开闭原则呢?
对于 Java 语言来说, 可以通过反射来避免对策略工厂类的修改. 具体是这么做的: 通过一个配置文件或者自定义的 annotation 来标注都有哪些策略类; 策略工厂类读取配置文件或者搜索被 annotation 标注的策略类, 然后通过反射了动态地加载这些策略类, 创建策略对象; 当新添加一个策略的时候, 只需要将这个新添加的策略类添加到配置文件或者用 annotation 标注即可.
对于 Sorter 来说, 可以通过同样的方法来避免修改. 通过将文件大小区间和算法之间的对应关系放到配置文件中. 当添加新的排序算法时, 只需要改动配置文件即可, 不需要改动代码.
重点回顾
一提到 if-else 分支判断, 有人就觉得它是烂代码. 如果 if-else 分支判断不复杂, 代码不多, 这并没有任何问题, 毕竟 if-else 分支判断几乎是所有编程语言都会提供的语法, 存在即有理由. 遵循 KISS 原则, 怎么简单怎么来, 就是最好的设计. 非得用策略模式, 搞出 n 多类, 反倒是一种过度设计.
一提到策略模式, 有人就觉得, 它的作用是避免 if-else 分支判断逻辑. 实际上, 这种认识是很片面的. 策略模式主要的作用还是解耦策略的定义, 创建和使用, 控制代码的复杂度, 让每个部分都不至于过于复杂, 代码量过多. 除此之外, 对于复杂代码来说, 策略模式还能让其满足开闭原则, 添加新策略的时候, 最小化, 集中化代码改动, 减少引入 bug 的风险.
实际上, 设计原则和思想比设计模式更加普适和重要. 掌握了代码的设计原则和思想, 就能更清楚的了解, 为什么要用某种设计模式, 就能更恰到好处地应用设计模式.
# 62-职责链模式(上):如何实现可灵活扩展算法的敏感信息过滤框架?
前学习了模板模式, 策略模式, 今天来学习职责链模式. 这三种模式具有相同的作用: 复用和扩展, 在实际的项目开发中比较常用, 特别是框架开发中, 可以利用它们来提供框架的扩展点, 能够让框架的使用者在不修改框架源码的情况下, 基于扩展点定制化框架的功能.
今天主要讲解职责链模式的原理和实现. 除此之外, 还会利用职责链模式, 实现一个可以灵活扩展算法的敏感词过滤框架. 下一节会更加贴近实战, 通过剖析 Servlet Filter, Spring Interceptor 来看, 如何利用职责链模式实现框架中常用的过滤器, 拦截器.
# 1.职责链模式的原理和实现
职责链模式的英文翻译是 Chain Of Responsibility Design Pattern. 在 GoF 的《设计模式》中, 它是这么定义的:
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it.
翻译成中文就是: 将请求的发送和接收解耦, 让多个接收对象都有机会处理这个请求. 将这些接收对象串成一条链, 并沿着这条链传递这个请求, 直到链上的某个接收对象能够处理它为止.
这么说比较抽象, 下面用更加容易理解的话来进一步解读一下.
在职责链模式中, 多个处理器(也就是刚刚定义中说的"接收对象")依次处理同一个请求. 一个请求先经过 A 处理器处理, 然后再把请求传递给 B 处理器, B 处理器处理完后再传递给 C 处理器, 以此类推, 形成一个链条. 链条上的每个处理器各自承担各自的处理职责, 所以叫作职责链模式.
关于职责链模式, 先来看看它的代码实现. 结合代码实现, 会更容易理解它的定义. 职责链模式有多种实现方式, 这里介绍两种比较常用的.
第一种实现方式如下所示. 其中 Handler 是所有处理器类的抽象父类, handle() 是抽象方法. 每个具体的处理器类(HandlerA, HandlerB)的 handle() 函数的代码结构类似, 如果它能处理该请求, 就不继续往下传递; 如果不能处理, 则交由后面的处理器来处理(也就是调用 successor.handle()). HandlerChain 是处理器链, 从数据结构的角度来看, 它就是一个记录了链头, 链尾的链表. 其中, 记录链尾是为了方便添加处理器.
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public abstract void handle();
}
2
3
4
5
6
7
8
9
public class HandlerA extends Handler {
@Override
public boolean handle() {
boolean handled = false;
//...
if (!handled && successor != null) {
successor.handle();
}
}
}
2
3
4
5
6
7
8
9
10
public class HandlerB extends Handler {
@Override
public void handle() {
boolean handled = false;
//...
if (!handled && successor != null) {
successor.handle();
}
}
}
2
3
4
5
6
7
8
9
10
public class HandlerChain {
private Handler head = null;
private Handler tail = null;
public void addHandler(Handler handler) {
handler.setSuccessor(null);
if (head == null) {
head = handler;
tail = handler;
return;
}
tail.setSuccessor(handler);
tail = handler;
}
public void handle() {
if (head != null) {
head.handle();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
2
3
4
5
6
7
8
9
实际上, 上面的代码实现不够优雅. 处理器类的 handle() 函数, 不仅包含自己的业务逻辑, 还包含对下一个处理器的调用, 也就是代码中的 successor.handle() . 一个不熟悉这种代码结构的程序员, 在添加新的处理器类的时候, 很有可能忘记在 handle() 函数中调用 successor.handle(), 这就会导致代码出现 bug.
针对这个问题, 对代码进行重构, 利用模板模式, 将调用 successor.handle() 的逻辑从具体的处理器类中剥离出来, 放到抽象父类中. 这样具体的处理器类只需要实现自己的业务逻辑就可以了. 重构之后的代码如下所示:
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public final void handle() {
boolean handled = doHandle();
if (successor != null && !handled) {
successor.handle();
}
}
protected abstract boolean doHandle();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HandlerA extends Handler {
@Override
protected boolean doHandle() {
boolean handled = false;
//...
return handled;
}
}
2
3
4
5
6
7
8
public class HandlerB extends Handler {
@Override
protected boolean doHandle() {
boolean handled = false;
//...
return handled;
}
}
2
3
4
5
6
7
8
// HandlerChain和Application代码不变
再来看第二种实现方式, 代码如下所示. 这种实现方式更加简单. HandlerChain 类用数组而非链表来保存所有的处理器, 并且需要在 HandlerChain 的 handle() 函数中, 依次调用每个处理器的 handle() 函数.
public interface IHandler {
boolean handle();
}
2
3
public class HandlerA implements IHandler {
@Override
public boolean handle() {
boolean handled = false;
//...
return handled;
}
}
2
3
4
5
6
7
8
public class HandlerB implements IHandler {
@Override
public boolean handle() {
boolean handled = false;
//...
return handled;
}
}
2
3
4
5
6
7
8
public class HandlerChain {
private List<IHandler> handlers = new ArrayList<>();
public void addHandler(IHandler handler) {
this.handlers.add(handler);
}
public void handle() {
for (IHandler handler : handlers) {
boolean handled = handler.handle();
if (handled) {
break;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
2
3
4
5
6
7
8
9
在 GoF 给出的定义中, 如果处理器链上的某个处理器能够处理这个请求, 那就不会继续往下传递请求. 实际上, 职责链模式还有一种变体, 那就是请求会被所有的处理器都处理一遍, 不存在中途终止的情况. 这种变体也有两种实现方式: 用链表存储处理器和用数组存储处理器, 跟上面的两种实现方式类似, 只需要稍微修改即可.
这里只给出其中一种实现方式, 如下所示. 另外一种实现方式你对照着上面的实现自行修改.
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public final void handle() {
doHandle();
if (successor != null) {
successor.handle();
}
}
protected abstract void doHandle();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HandlerA extends Handler {
@Override
protected void doHandle() {
//...
}
}
2
3
4
5
6
public class HandlerB extends Handler {
@Override
protected void doHandle() {
//...
}
}
2
3
4
5
6
public class HandlerChain {
private Handler head = null;
private Handler tail = null;
public void addHandler(Handler handler) {
handler.setSuccessor(null);
if (head == null) {
head = handler;
tail = handler;
return;
}
tail.setSuccessor(handler);
tail = handler;
}
public void handle() {
if (head != null) {
head.handle();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
2
3
4
5
6
7
8
9
# 2.职责链模式的应用场景
举例职责链模式的原理和实现讲完了, 再通过一个实际的例子, 来学习一下职责链模式的应用场景.
对于支持 UGC(User Generated Content, 用户生成内容)的应用(比如论坛)来说, 用户生成的内容(比如, 在论坛中发表的帖子)可能会包含一些敏感词(比如涉黄, 广告, 反动等词汇). 针对这个应用场景, 就可以利用职责链模式来过滤这些敏感词.
对于包含敏感词的内容, 有两种处理方式, 一种是直接禁止发布, 另一种是给敏感词打马赛克(比如, 用 * 替换敏感词)之后再发布. 第一种处理方式符合 GoF 给出的职责链模式的定义, 第二种处理方式是职责链模式的变体.
这里只给出第一种实现方式的代码示例, 并且只给出了代码实现的骨架, 具体的敏感词过滤算法并没有给出.
public interface SensitiveWordFilter {
boolean doFilter(Content content);
}
2
3
public class SexyWordFilter implements SensitiveWordFilter {
@Override
public boolean doFilter(Content content) {
boolean legal = true;
//...
return legal;
}
}
2
3
4
5
6
7
8
// PoliticalWordFilter, AdsWordFilter类代码结构与SexyWordFilter类似
public class SensitiveWordFilterChain {
private List<SensitiveWordFilter> filters = new ArrayList<>();
public void addFilter(SensitiveWordFilter filter) {
this.filters.add(filter);
}
// return true if content doesn't contain sensitive words.
public boolean filter(Content content) {
for (SensitiveWordFilter filter : filters) {
if (!filter.doFilter(content)) {
return false;
}
}
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ApplicationDemo {
public static void main(String[] args) {
SensitiveWordFilterChain filterChain = new SensitiveWordFilterChain();
filterChain.addFilter(new AdsWordFilter());
filterChain.addFilter(new SexyWordFilter());
filterChain.addFilter(new PoliticalWordFilter());
boolean legal = filterChain.filter(new Content());
if (!legal) {
// 不发表
} else {
// 发表
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
看了上面的实现, 你可能会说, 像下面这样也可以实现敏感词过滤功能, 而且代码更加简单, 为什么非要使用职责链模式呢? 这是不是过度设计呢?
public class SensitiveWordFilter {
// return true if content doesn't contain sensitive words.
public boolean filter(Content content) {
if (!filterSexyWord(content)) {
return false;
}
if (!filterAdsWord(content)) {
return false;
}
if (!filterPoliticalWord(content)) {
return false;
}
return true;
}
private boolean filterSexyWord(Content content) {
//....
}
private boolean filterAdsWord(Content content) {
//...
}
private boolean filterPoliticalWord(Content content) {
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
前面多次讲过, 应用设计模式主要是为了应对代码的复杂性, 让其满足开闭原则, 提高代码的扩展性. 这里应用职责链模式也不例外. 实际上在讲解策略模式的时候, 也讲过类似的问题, 比如, 为什么要用策略模式? 当时的给出的理由, 与现在应用职责链模式的理由, 几乎是一样的, 可以结合着当时的讲解一块来看下.
首先来看, 职责链模式如何应对代码的复杂性.
将大块代码逻辑拆分成函数, 将大类拆分成小类, 是应对代码复杂性的常用方法. 应用职责链模式, 把各个敏感词过滤函数继续拆分出来, 设计成独立的类, 进一步简化了 SensitiveWordFilter 类, 让 SensitiveWordFilter 类的代码不会过多, 过复杂.
其次再来看, 职责链模式如何让代码满足开闭原则, 提高代码的扩展性.
当要扩展新的过滤算法的时候, 比如还需要过滤特殊符号, 按照非职责链模式的代码实现方式, 需要修改 SensitiveWordFilter 的代码, 违反开闭原则. 不过这样的修改还算比较集中, 也是可以接受的. 而职责链模式的实现方式更加优雅, 只需要新添加一个 Filter 类, 并且通过 addFilter() 函数将它添加到 FilterChain 中即可, 其他代码完全不需要修改.
不过, 你可能会说, 即便使用职责链模式来实现, 当添加新的过滤算法的时候, 还是要修改客户端代码(ApplicationDemo), 这样做也没有完全符合开闭原则.
实际上, 细化一下的话, 可以把上面的代码分成两类: 框架代码和客户端代码. 其中, ApplicationDemo 属于客户端代码, 也就是使用框架的代码. 除 ApplicationDemo 之外的代码属于敏感词过滤框架代码.
假设敏感词过滤框架并不是自己开发维护的, 而是引入的一个第三方框架, 要扩展一个新的过滤算法, 不可能直接去修改框架的源码. 这个时候, 利用职责链模式就能达到开篇所说的, 在不修改框架源码的情况下, 基于职责链模式提供的扩展点, 来扩展新的功能. 换句话说, 我们在框架这个代码范围内实现了开闭原则.
除此之外, 利用职责链模式相对于不用职责链的实现方式, 还有一个好处, 那就是配置过滤算法更加灵活, 可以只选择使用某几个过滤算法.
重点回顾
在职责链模式中, 多个处理器依次处理同一个请求. 一个请求先经过 A 处理器处理, 然后再把请求传递给 B 处理器, B 处理器处理完后再传递给 C 处理器, 以此类推, 形成一个链条. 链条上的每个处理器各自承担各自的处理职责, 所以叫作职责链模式.
在 GoF 的定义中, 一旦某个处理器能处理这个请求, 就不会继续将请求传递给后续的处理器了. 当然, 在实际的开发中, 也存在对这个模式的变体, 那就是请求不会中途终止传递, 而是会被所有的处理器都处理一遍.
职责链模式有两种常用的实现. 一种是使用链表来存储处理器, 另一种是使用数组来存储处理器, 后面一种实现方式更加简单.
# 63-职责链模式(下):框架中常用的过滤器,拦截器是如何实现的?
上一节学习职责链模式的原理与实现, 并且通过一个敏感词过滤框架的例子, 展示了职责链模式的设计意图. 本质上来说, 它跟大部分设计模式一样, 都是为了解耦代码, 应对代码的复杂性, 让代码满足开闭原则, 提高代码的可扩展性.
除此之外还提到, 职责链模式常用在框架的开发中, 为框架提供扩展点, 让框架的使用者在不修改框架源码的情况下, 基于扩展点添加新的功能. 实际上, 更具体点来说, 职责链模式最常用来开发框架的过滤器和拦截器. 今天就通过 Servlet Filter, Spring Interceptor 这两个 Java 开发中常用的组件, 来具体讲讲它在框架开发中的应用.
# 1.Servlet Filter
Servlet Filter 是 Java Servlet 规范中定义的组件, 翻译成中文就是过滤器, 它可以实现对 HTTP 请求的过滤功能, 比如鉴权, 限流, 记录日志, 验证参数等等. 因为它是 Servlet 规范的一部分, 所以只要是支持 Servlet 的 Web 容器(比如, Tomcat, Jetty 等), 都支持过滤器功能. 下面画了一张示意图阐述它的工作原理, 如下所示.

在实际项目中, 该如何使用 Servlet Filter 呢? 一个简单的示例代码如下. 添加一个过滤器, 只需要定义一个实现 javax.servlet.Filter 接口的过滤器类, 并且将它配置在 web.xml 配置文件中. Web 容器启动的时候, 会读取 web.xml 中的配置, 创建过滤器对象. 当有请求到来的时候, 会先经过过滤器, 然后才由 Servlet 来处理.
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 在创建Filter时自动调用,
// 其中filterConfig包含这个Filter的配置参数, 比如name之类的(从配置文件中读取的)
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截客户端发送来的请求.");
chain.doFilter(request, response);
System.out.println("拦截发送给客户端的响应.");
}
@Override
public void destroy() {
// 在销毁Filter时自动调用
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在 web.xml 配置文件中如下配置:
<filter>
<filter-name>logFilter</filter-name>
<filter-class>com.xzg.cd.LogFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>logFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
2
3
4
5
6
7
8
可以看到, 添加过滤器非常方便, 不需要修改任何代码, 定义一个实现 javax.servlet.Filter 的类, 再改改配置就搞定了, 完全符合开闭原则. 那 Servlet Filter 是如何做到如此好的扩展性的呢? 其实它利用的就是职责链模式. 现在通过剖析它的源码, 详细地看看它底层是如何实现的.
在上一节讲到, 职责链模式的实现包含处理器接口(IHandler)或抽象类(Handler), 以及处理器链(HandlerChain). 对应到 Servlet Filter, javax.servlet.Filter 就是处理器接口, FilterChain 就是处理器链. 接下来重点来看 FilterChain 是如何实现的.
不过前面也讲过, Servlet 只是一个规范, 并不包含具体的实现, 所以 Servlet 中的 FilterChain 只是一个接口定义. 具体的实现类由遵从 Servlet 规范的 Web 容器来提供, 比如, ApplicationFilterChain 类就是 Tomcat 提供的 FilterChain 的实现类, 源码如下所示.
为了让代码更易读懂, 对代码进行了简化, 只保留了跟设计思路相关的代码片段. 完整代码可以去 Tomcat 中查看.
public final class ApplicationFilterChain implements FilterChain {
private int pos = 0; // 当前执行到了哪个filter
private int n; // filter的个数
private ApplicationFilterConfig[] filters;
private Servlet servlet;
@Override
public void doFilter(ServletRequest request, ServletResponse response) {
if (pos < n) {
// 使用数组存储filter
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
filter.doFilter(request, response, this);
} else {
// filter都处理完毕后, 执行servlet
servlet.service(request, response);
}
}
public void addFilter(ApplicationFilterConfig filterConfig) {
for (ApplicationFilterConfig filter:filters)
if (filter<mark>filterConfig)
return;
if (n </mark> filters.length) { // 扩容
ApplicationFilterConfig[] newFilters = new ApplicationFilterConfig[n + INCREMENT];
System.arraycopy(filters, 0, newFilters, 0, n);
filters = newFilters;
}
filters[n++] = filterConfig;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ApplicationFilterChain 中的 doFilter() 函数的代码实现比较有技巧, 实际上是一个递归调用. 你可以用每个 Filter(比如 LogFilter)的 doFilter() 的代码实现, 直接替换 ApplicationFilterChain 的第 12 行代码, 一眼就能看出是递归调用了. 这里替换了一下, 如下所示.
@Override
public void doFilter(ServletRequest request, ServletResponse response) {
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
// filter.doFilter(request, response, this);
// 把filter.doFilter的代码实现展开替换到这里
System.out.println("拦截客户端发送来的请求.");
chain.doFilter(request, response); // chain就是this
System.out.println("拦截发送给客户端的响应.")
} else {
// filter都处理完毕后, 执行servlet
servlet.service(request, response);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这样实现主要是为了在一个 doFilter() 方法中, 支持双向拦截, 既能拦截客户端发送来的请求, 也能拦截发送给客户端的响应, 你可以结合着 LogFilter 那个例子, 以及对比待会要讲到的 Spring Interceptor, 来自己理解一下. 而上一节给出的两种实现方式, 都没法做到在业务逻辑执行的前后, 同时添加处理代码.
# 2.Spring Interceptor
刚刚讲了 Servlet Filter, 现在来讲一个功能上跟它非常类似的东西, Spring Interceptor, 翻译成中文就是拦截器. 尽管英文单词和中文翻译都不同, 但这两者基本上可以看作一个概念, 都用来实现对 HTTP 请求进行拦截处理.
它们不同之处在于, Servlet Filter 是 Servlet 规范的一部分, 实现依赖于 Web 容器. Spring Interceptor 是 Spring MVC 框架的一部分, 由 Spring MVC 框架来提供实现. 客户端发送的请求, 会先经过 Servlet Filter, 然后再经过 Spring Interceptor, 最后到达具体的业务代码中. 下图可以阐述一个请求的处理流程, 具体如下所示.

在项目中, 该如何使用 Spring Interceptor 呢? 下面是一个简单的示例代码. LogInterceptor 实现的功能跟刚才的 LogFilter 完全相同, 只是实现方式上稍有区别. LogFilter 对请求和响应的拦截是在 doFilter() 一个函数中实现的, 而 LogInterceptor 对请求的拦截在 preHandle() 中实现, 对响应的拦截在 postHandle() 中实现.
public class LogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("拦截客户端发送来的请求.");
return true; // 继续后续的处理
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("拦截发送给客户端的响应.");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("这里总是被执行.");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这里也需要在 Spring MVC 配置文件中配置 interceptors.
同样还是来剖析一下, Spring Interceptor 底层是如何实现的.
当然, 它也是基于职责链模式实现的. 其中, HandlerExecutionChain 类是职责链模式中的处理器链. 它的实现相较于 Tomcat 中的 ApplicationFilterChain 来说, 逻辑更加清晰, 不需要使用递归来实现, 主要是因为它将请求和响应的拦截工作, 拆分到了两个函数中实现. HandlerExecutionChain 的源码如下所示, 同样这里对代码也进行了一些简化, 只保留了关键代码.
public class HandlerExecutionChain {
private final Object handler;
private HandlerInterceptor[] interceptors;
public void addInterceptor(HandlerInterceptor interceptor) {
initInterceptorList().add(interceptor);
}
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = 0; i < interceptors.length; i++) {
HandlerInterceptor interceptor = interceptors[i];
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
}
}
return true;
}
void applyPostHandle(HttpServletRequest request, HttpServletResponse response, ModelAndView mv) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = interceptors.length - 1; i >= 0; i--) {
HandlerInterceptor interceptor = interceptors[i];
interceptor.postHandle(request, response, this.handler, mv);
}
}
}
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, Exception ex)
throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = this.interceptorIndex; i >= 0; i--) {
HandlerInterceptor interceptor = interceptors[i];
try {
interceptor.afterCompletion(request, response, this.handler, ex);
} catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
在 Spring 框架中, DispatcherServlet 的 doDispatch() 方法来分发请求, 它在真正的业务逻辑执行前后, 执行 HandlerExecutionChain 中的 applyPreHandle() 和 applyPostHandle() 函数, 用来实现拦截的功能.
重点回顾
职责链模式常用在框架开发中, 用来实现框架的过滤器, 拦截器功能, 让框架的使用者在不需要修改框架源码的情况下, 添加新的过滤拦截功能. 这也体现了之前讲到的对扩展开放, 对修改关闭的设计原则.
今天通过 Servlet Filter, Spring Interceptor 两个实际的例子, 展示了在框架开发中职责链模式具体是怎么应用的. 从源码中还可以发现, 尽管上一节有给出职责链模式的经典代码实现, 但在实际的开发中, 还是要具体问题具体对待, 代码实现会根据不同的需求有所变化. 实际上, 这一点对于所有的设计模式都适用.
# 64-状态模式:游戏,工作流引擎中常用的状态机是如何实现的?
本节开始学习状态模式. 在实际的软件开发中, 状态模式并不是很常用, 但是在能够用到的场景里, 它可以发挥很大的作用. 从这一点上来看, 它有点像之前讲到的组合模式.
状态模式一般用来实现状态机, 而状态机常用在游戏, 工作流引擎等系统开发中. 不过, 状态机的实现方式有多种, 除了状态模式, 比较常用的还有分支逻辑法和查表法. 今天就详细讲讲这几种实现方式, 并且对比一下它们的优劣和应用场景.
# 1.什么是有限状态机?
有限状态机, 英文翻译是 Finite State Machine, 缩写为 FSM, 简称为状态机. 状态机有 3 个组成部分: 状态(State), 事件(Event), 动作(Action) . 其中, 事件也称为转移条件(Transition Condition). 事件触发状态的转移及动作的执行. 不过, 动作不是必须的, 也可能只转移状态, 不执行任何动作.
对于刚刚给出的状态机的定义, 结合一个具体的例子来进一步解释一下.
"超级马里奥" 游戏中, 马里奥可以变身为多种形态, 比如小马里奥(Small Mario), 超级马里奥(Super Mario), 火焰马里奥(Fire Mario), 斗篷马里奥(Cape Mario)等等. 在不同的游戏情节下, 各个形态会互相转化, 并相应的增减积分. 比如, 初始形态是小马里奥, 吃了蘑菇之后就会变成超级马里奥, 并且增加 100 积分.
实际上, 马里奥形态的转变就是一个状态机. 其中, 马里奥的不同形态就是状态机中的 "状态", 游戏情节(比如吃了蘑菇)就是状态机中的 "事件", 加减积分就是状态机中的 "动作". 比如, 吃蘑菇这个事件, 会触发状态的转移: 从小马里奥转移到超级马里奥, 以及触发动作的执行(增加 100 积分).
为了方便接下来的讲解, 对游戏背景做了简化, 只保留了部分状态和事件. 简化之后的状态转移如下图所示:

如何编程来实现上面的状态机呢? 换句话说, 如何将上面的状态转移图翻译成代码呢?
下面是一个骨架代码. 其中, obtainMushRoom(), obtainCape(), obtainFireFlower(), meetMonster() 这几个函数, 能够根据当前的状态和事件, 更新状态和增减积分. 不过, 具体的代码实现暂时并没有给出.
public enum State {
SMALL(0),
SUPER(1),
FIRE(2),
CAPE(3);
private int value;
private State(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MarioStateMachine {
private int score;
private State currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
// TODO
}
public void obtainCape() {
// TODO
}
public void obtainFireFlower() {
// TODO
}
public void meetMonster() {
// TODO
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ApplicationDemo {
public static void main(String[] args) {
MarioStateMachine mario = new MarioStateMachine();
mario.obtainMushRoom();
int score = mario.getScore();
State state = mario.getCurrentState();
System.out.println("mario score: " + score + "; state: " + state);
}
}
2
3
4
5
6
7
8
9
# 2.状态机实现方式一:分支逻辑法
对于如何实现状态机, 我总结了三种方式. 其中, 最简单直接的实现方式是, 参照状态转移图, 将每一个状态转移, 原模原样地直译成代码. 这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑, 甚至是嵌套的分支判断逻辑, 所以把这种方法暂且命名为分支逻辑法.
按照这个实现思路, 将上面的骨架代码补全一下.
public class MarioStateMachine {
private int score;
private State currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
if (currentState.equals(State.SMALL)) {
this.currentState = State.SUPER;
this.score += 100;
}
}
public void obtainCape() {
if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
this.currentState = State.CAPE;
this.score += 200;
}
}
public void obtainFireFlower() {
if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
this.currentState = State.FIRE;
this.score += 300;
}
}
public void meetMonster() {
if (currentState.equals(State.SUPER)) {
this.currentState = State.SMALL;
this.score -= 100;
return;
}
if (currentState.equals(State.CAPE)) {
this.currentState = State.SMALL;
this.score -= 200;
return;
}
if (currentState.equals(State.FIRE)) {
this.currentState = State.SMALL;
this.score -= 300;
return;
}
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
对于简单的状态机来说, 分支逻辑这种实现方式是可以接受的. 但对于复杂的状态机来说, 这种实现方式极易漏写或者错写某个状态转移. 除此之外, 代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑, 可读性和可维护性都很差. 如果哪天修改了状态机中的某个状态转移, 要在冗长的分支逻辑中找到对应的代码进行修改, 很容易改错, 引入 bug.
# 3.状态机实现方式二:查表法
实际上, 上面这种实现方法有点类似 hard code, 对于复杂的状态机来说不适用, 而状态机的第二种实现方式查表法, 就更加合适了. 接下来看下, 如何利用查表法来补全骨架代码.
实际上, 除了用状态转移图来表示之外, 状态机还可以用二维表来表示, 如下所示. 在这个二维表中, 第一维表示当前状态, 第二维表示事件, 值表示当前状态经过事件之后, 转移到的新状态及其执行的动作.

相对于分支逻辑的实现方式, 查表法的代码实现更加清晰, 可读性和可维护性更好. 当修改状态机时, 只需要修改 transitionTable 和 actionTable 两个二维数组即可. 实际上, 如果把这两个二维数组存储在配置文件中, 当需要修改状态机时, 甚至可以不修改任何代码, 只需要修改配置文件就可以了. 具体的代码如下所示:
public enum Event {
GOT_MUSHROOM(0),
GOT_CAPE(1),
GOT_FIRE(2),
MET_MONSTER(3);
private int value;
private Event(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MarioStateMachine {
private int score;
private State currentState;
private static final State[][] transitionTable = {
{SUPER, CAPE, FIRE, SMALL},
{SUPER, CAPE, FIRE, SMALL},
{CAPE, CAPE, CAPE, SMALL},
{FIRE, FIRE, FIRE, SMALL}
};
private static final int[][] actionTable = {
{+100, +200, +300, +0},
{+0, +200, +300, -100},
{+0, +0, +0, -200},
{+0, +0, +0, -300}
};
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
executeEvent(Event.GOT_MUSHROOM);
}
public void obtainCape() {
executeEvent(Event.GOT_CAPE);
}
public void obtainFireFlower() {
executeEvent(Event.GOT_FIRE);
}
public void meetMonster() {
executeEvent(Event.MET_MONSTER);
}
private void executeEvent(Event event) {
int stateValue = currentState.getValue();
int eventValue = event.getValue();
this.currentState = transitionTable[stateValue][eventValue];
this.score = actionTable[stateValue][eventValue];
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 4.状态机实现方式三:状态模式
在查表法的代码实现中, 事件触发的动作只是简单的积分加减, 所以用一个 int 类型的二维数组 actionTable 就能表示, 二维数组中的值表示积分的加减值. 但如果要执行的动作并非这么简单, 而是一系列复杂的逻辑操作(比如加减积分, 写数据库, 还有可能发送消息通知等等), 就没法用如此简单的二维数组来表示了. 这也就是说, 查表法的实现方式有一定局限性.
虽然分支逻辑的实现方式不存在这个问题, 但它又存在前面讲到的其他问题, 比如分支判断逻辑较多, 导致代码可读性和可维护性不好等. 实际上, 针对分支逻辑法存在的问题, 所以可以使用状态模式来解决.
状态模式通过将事件触发的状态转移和动作执行, 拆分到不同的状态类中, 来避免分支判断逻辑. 还是结合代码来理解这句话.
利用状态模式来补全 MarioStateMachine 类, 补全后的代码如下所示.
其中, IMario 是状态的接口, 定义了所有的事件. SmallMario, SuperMario, CapeMario, FireMario 是 IMario 接口的实现类, 分别对应状态机中的 4 个状态. 原来所有的状态转移和动作执行的代码逻辑, 都集中在 MarioStateMachine 类中, 现在这些代码逻辑被分散到了这 4 个状态类中.
public interface IMario { // 所有状态类的接口
State getName();
// 以下是定义的事件
void obtainMushRoom();
void obtainCape();
void obtainFireFlower();
void meetMonster();
}
2
3
4
5
6
7
8
public class SmallMario implements IMario {
private MarioStateMachine stateMachine;
public SmallMario(MarioStateMachine stateMachine) {
this.stateMachine = stateMachine;
}
@Override
public State getName() {
return State.SMALL;
}
@Override
public void obtainMushRoom() {
stateMachine.setCurrentState(new SuperMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 100);
}
@Override
public void obtainCape() {
stateMachine.setCurrentState(new CapeMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower() {
stateMachine.setCurrentState(new FireMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster() {
// do nothing...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class SuperMario implements IMario {
private MarioStateMachine stateMachine;
public SuperMario(MarioStateMachine stateMachine) {
this.stateMachine = stateMachine;
}
@Override
public State getName() {
return State.SUPER;
}
@Override
public void obtainMushRoom() {
// do nothing...
}
@Override
public void obtainCape() {
stateMachine.setCurrentState(new CapeMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower() {
stateMachine.setCurrentState(new FireMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster() {
stateMachine.setCurrentState(new SmallMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() - 100);
}
}
// 省略CapeMario, FireMario类...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MarioStateMachine {
private int score;
private IMario currentState; // 不再使用枚举来表示状态
public MarioStateMachine() {
this.score = 0;
this.currentState = new SmallMario(this);
}
public void obtainMushRoom() {
this.currentState.obtainMushRoom();
}
public void obtainCape() {
this.currentState.obtainCape();
}
public void obtainFireFlower() {
this.currentState.obtainFireFlower();
}
public void meetMonster() {
this.currentState.meetMonster();
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState.getName();
}
public void setScore(int score) {
this.score = score;
}
public void setCurrentState(IMario currentState) {
this.currentState = currentState;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
上面的代码实现不难看懂, 只强调其中的一点, 即 MarioStateMachine 和各个状态类之间是双向依赖关系. MarioStateMachine 依赖各个状态类是理所当然的, 但反过来, 各个状态类为什么要依赖 MarioStateMachine 呢? 这是因为, 各个状态类需要更新 MarioStateMachine 中的两个变量, score 和 currentState.
实际上, 上面的代码还可以继续优化, 可以将状态类设计成单例, 毕竟状态类中不包含任何成员变量. 但当将状态类设计成单例之后, 就无法通过构造函数来传递 MarioStateMachine 了, 而状态类又要依赖 MarioStateMachine, 那该如何解决这个问题呢?
实际上, 在讲单例模式的讲解中, 提到过几种解决方法, 可以再看一下. 在这里可以通过函数参数将 MarioStateMachine 传递进状态类. 根据这个设计思路, 对上面的代码进行重构. 重构之后的代码如下所示:
public interface IMario {
State getName();
void obtainMushRoom(MarioStateMachine stateMachine);
void obtainCape(MarioStateMachine stateMachine);
void obtainFireFlower(MarioStateMachine stateMachine);
void meetMonster(MarioStateMachine stateMachine);
}
2
3
4
5
6
7
public class SmallMario implements IMario {
private static final SmallMario instance = new SmallMario();
private SmallMario() {}
public static SmallMario getInstance() {
return instance;
}
@Override
public State getName() {
return State.SMALL;
}
@Override
public void obtainMushRoom(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(SuperMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 100);
}
@Override
public void obtainCape(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(CapeMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(FireMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster(MarioStateMachine stateMachine) {
// do nothing...
}
}
// 省略SuperMario, CapeMario, FireMario类...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MarioStateMachine {
private int score;
private IMario currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = SmallMario.getInstance();
}
public void obtainMushRoom() {
this.currentState.obtainMushRoom(this);
}
public void obtainCape() {
this.currentState.obtainCape(this);
}
public void obtainFireFlower() {
this.currentState.obtainFireFlower(this);
}
public void meetMonster() {
this.currentState.meetMonster(this);
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState.getName();
}
public void setScore(int score) {
this.score = score;
}
public void setCurrentState(IMario currentState) {
this.currentState = currentState;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
实际上, 像游戏这种比较复杂的状态机, 包含的状态比较多, 其实优先推荐使用查表法, 而状态模式会引入非常多的状态类, 会导致代码比较难维护. 相反, 像电商下单, 外卖下单这种类型的状态机, 它们的状态并不多, 状态转移也比较简单, 但事件触发执行的动作包含的业务逻辑可能会比较复杂, 所以更加推荐使用状态模式来实现.
重点回顾
虽然网上有各种状态模式的定义, 但是只要记住状态模式是状态机的一种实现方式即可. 状态机又叫有限状态机, 它有 3 个部分组成: 状态, 事件, 动作. 其中, 事件也称为转移条件. 事件触发状态的转移及动作的执行. 不过, 动作不是必须的, 也可能只转移状态, 不执行任何动作.
针对状态机, 今天总结了三种实现方式.
第一种实现方式叫分支逻辑法. 利用 if-else 或者 switch-case 分支逻辑, 参照状态转移图, 将每一个状态转移原模原样地直译成代码. 对于简单的状态机来说, 这种实现方式最简单, 最直接, 是首选.
第二种实现方式叫查表法. 对于状态很多, 状态转移比较复杂的状态机来说, 查表法比较合适. 通过二维数组来表示状态转移图, 能极大地提高代码的可读性和可维护性.
第三种实现方式叫状态模式. 对于状态并不多, 状态转移也比较简单, 但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说, 首选这种实现方式.
# 65-迭代器模式(上):相比直接遍历集合数据,使用迭代器有哪些优势?
今天学习另外一种行为型设计模式, 迭代器模式. 它用来遍历集合对象. 不过, 很多编程语言都将迭代器作为一个基础的类库, 直接提供出来了. 在平时开发中, 特别是业务开发, 直接使用即可, 很少会自己去实现一个迭代器. 不过, 知其然知其所以然, 弄懂原理能帮助我们更好的使用这些工具类.
大部分编程语言都提供了多种遍历集合的方式, 比如 for 循环, foreach 循环, 迭代器等. 所以, 除了讲解迭代器的原理和实现之外, 还会讲一下, 相对于其他遍历方式, 利用迭代器来遍历集合的优势.
# 1.迭代器模式的原理和实现
迭代器模式(Iterator Design Pattern), 也叫作游标模式(Cursor Design Pattern). 它用来遍历集合对象. 这里说的 "集合对象" 也可以叫"容器", "聚合对象", 实际上就是包含一组对象的对象, 比如数组, 链表, 树, 图, 跳表. 迭代器模式将集合对象的遍历操作从集合类中拆分出来, 放到迭代器类中, 让两者的职责更加单一.
迭代器是用来遍历容器的, 所以一个完整的迭代器模式一般会涉及容器和容器迭代器两部分内容. 为了达到基于接口而非实现编程的目的, 容器又包含容器接口, 容器实现类, 迭代器又包含迭代器接口, 迭代器实现类. 对于迭代器模式, 我画了一张简单的类图, 你可以看一看.
接下来, 通过一个例子来具体讲, 如何实现一个迭代器.
大部分编程语言都提供了遍历容器的迭代器类, 在平时开发中, 直接拿来用即可, 几乎不大可能从零编写一个迭代器. 不过, 这里为了讲解迭代器的实现原理, 假设某个新的编程语言的基础类库中, 还没有提供线性容器对应的迭代器, 需要从零开始开发. 现在一块来看具体该如何去做.
线性数据结构包括数组和链表, 在大部分编程语言中都有对应的类来封装这两种数据结构, 在开发中直接拿来用就可以了. 假设在这种新的编程语言中, 这两个数据结构分别对应 ArrayList 和 LinkedList 两个类. 除此之外, 从两个类中抽象出公共的接口, 定义为 List 接口, 以方便开发者基于接口而非实现编程, 编写的代码能在两种数据存储结构之间灵活切换.
现在针对 ArrayList 和 LinkedList 两个线性容器, 设计实现对应的迭代器. 按照之前给出的迭代器模式的类图, 定义一个迭代器接口 Iterator, 以及针对两种容器的具体的迭代器实现类 ArrayIterator 和 ListIterator.
先来看下 Iterator 接口的定义. 具体的代码如下所示:
// 接口定义方式一
public interface Iterator<E> {
boolean hasNext();
void next();
E currentItem();
}
// 接口定义方式二
public interface Iterator<E> {
boolean hasNext();
E next();
}
2
3
4
5
6
7
8
9
10
11
12
Iterator 接口有两种定义方式.
在第一种定义中, next() 函数用来将游标后移一位元素, currentItem() 函数用来返回当前游标指向的元素. 在第二种定义中, 返回当前元素与后移一位这两个操作, 要放到同一个函数 next() 中完成.
第一种定义方式更加灵活一些, 比如可以多次调用 currentItem() 查询当前元素, 而不移动游标. 所以在接下来的实现中, 选择第一种接口定义方式.
现在, 再来看下 ArrayIterator 的代码实现, 具体如下所示. 代码实现非常简单, 不需要太多解释.
public class ArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> arrayList;
public ArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
// 注意这里, cursor在指向最后一个元素的时候, hasNext()仍旧返回true.
return cursor != arrayList.size();
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor >= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("xzg");
names.add("wang");
names.add("zheng");
Iterator<String> iterator = new ArrayIterator(names);
while (iterator.hasNext()) {
System.out.println(iterator.currentItem());
iterator.next();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的代码实现中, 需要将待遍历的容器对象, 通过构造函数传递给迭代器类. 实际上, 为了封装迭代器的创建细节, 可以在容器中定义一个 iterator() 方法, 来创建对应的迭代器. 为了能实现基于接口而非实现编程, 还需要将这个方法定义在 List 接口中. 具体的代码实现和使用示例如下所示:
public interface List<E> {
Iterator iterator();
//...省略其他接口函数...
}
2
3
4
public class ArrayList<E> implements List<E> {
// ...
public Iterator iterator() {
return new ArrayIterator(this);
}
// ...省略其他代码
}
2
3
4
5
6
7
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("xzg");
names.add("wang");
names.add("zheng");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.currentItem());
iterator.next();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
对于 LinkedIterator, 它的代码结构跟 ArrayIterator 完全相同, 这里就不给出具体的代码实现了, 可以参照 ArrayIterator 自己去写一下.
结合刚刚的例子, 来总结一下迭代器的设计思路. 总结下来就三句话: 迭代器中需要定义 hasNext(), currentItem(), next() 三个最基本的方法. 待遍历的容器对象通过依赖注入传递到迭代器类中. 容器通过 iterator() 方法来创建迭代器.
下面的类图就是对上面那张类图的细化, 可以结合着一块看.

# 2.迭代器模式的优势
迭代器的原理和代码实现讲完了. 接下来看一下, 使用迭代器遍历集合的优势.
一般来讲, 遍历集合数据有三种方法: for 循环, foreach 循环, iterator 迭代器. 对于这三种方式, 我拿 Java 语言来举例说明一下. 具体的代码如下所示:
List<String> names = new ArrayList<>();
names.add("xzg");
names.add("wang");
names.add("zheng");
// 第一种遍历方式: for循环
for (int i = 0; i < names.size(); i++) {
System.out.print(names.get(i) + ",");
}
// 第二种遍历方式: foreach循环
for (String name : names) {
System.out.print(name + ",")
}
// 第三种遍历方式: 迭代器遍历
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + ",");// Java中的迭代器接口是第二种定义方式, next()既移动游标又返回数据
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
实际上, foreach 循环只是一个语法糖而已, 底层是基于迭代器来实现的. 也就是说, 上面代码中的第二种遍历方式(foreach 循环代码)的底层实现, 就是第三种遍历方式(迭代器遍历代码). 这两种遍历方式可以看作同一种遍历方式, 也就是迭代器遍历方式.
从上面的代码来看, for 循环遍历方式比起迭代器遍历方式, 代码看起来更加简洁. 那为什么还要用迭代器来遍历容器呢? 为什么还要给容器设计对应的迭代器呢? 原因有以下三个.
首先, 对于类似数组和链表这样的数据结构, 遍历方式比较简单, 直接使用 for 循环来遍历就足够了. 但对于复杂的数据结构(比如树, 图)来说, 有各种复杂的遍历方式. 比如, 树有前中后序, 按层遍历, 图有深度优先, 广度优先遍历等等. 如果由客户端代码来实现这些遍历算法, 势必增加开发成本, 而且容易写错. 如果将这部分遍历的逻辑写到容器类中, 也会导致容器类代码的复杂性.
前面也多次提到, 应对复杂性的方法就是拆分. 可以将遍历操作拆分到迭代器类中. 比如针对图的遍历, 就可以定义 DFSIterator, BFSIterator 两个迭代器类, 让它们分别来实现深度优先遍历和广度优先遍历.
其次, 将游标指向的当前位置等信息, 存储在迭代器类中, 每个迭代器独享游标信息. 这样就可以创建多个不同的迭代器, 同时对同一个容器进行遍历而互不影响.
最后, 容器和迭代器都提供了抽象的接口, 方便在开发的时候, 基于接口而非具体的实现编程. 当需要切换新的遍历算法的时候, 比如从前往后遍历链表切换成从后往前遍历链表, 客户端代码只需要将迭代器类从 LinkedIterator 切换为 ReversedLinkedIterator 即可, 其他代码都不需要修改. 除此之外, 添加新的遍历算法, 只需要扩展新的迭代器类, 也更符合开闭原则.
重点回顾
迭代器模式, 也叫游标模式. 它用来遍历集合对象. 这里说的 "集合对象", 也可以叫"容器", "聚合对象", 实际上就是包含一组对象的对象, 比如, 数组, 链表, 树, 图, 跳表.
一个完整的迭代器模式, 一般会涉及容器和容器迭代器两部分内容. 为了达到基于接口而非实现编程的目的, 容器又包含容器接口, 容器实现类, 迭代器又包含迭代器接口, 迭代器实现类. 容器中需要定义 iterator() 方法, 用来创建迭代器. 迭代器接口中需要定义 hasNext(), currentItem(), next() 三个最基本的方法. 容器对象通过依赖注入传递到迭代器类中.
遍历集合一般有三种方式: for 循环, foreach 循环, 迭代器遍历. 后两种本质上属于一种, 都可以看作迭代器遍历. 相对于 for 循环遍历, 利用迭代器来遍历有下面三个优势:
- 迭代器模式封装集合内部的复杂数据结构, 开发者不需要了解如何遍历, 直接使用容器提供的迭代器即可;
- 迭代器模式将集合对象的遍历操作从集合类中拆分出来, 放到迭代器类中, 让两者的职责更加单一;
- 迭代器模式让添加新的遍历算法更加容易, 更符合开闭原则. 除此之外, 因为迭代器都实现自相同的接口, 在开发中, 基于接口而非实现编程, 替换迭代器也变得更加容易.
# 66-迭代器模式(中):遍历集合的同时,为什么不能增删集合元素?
上一节通过给 ArrayList, LinkedList 容器实现迭代器, 学习了迭代器模式的原理, 实现和设计意图. 迭代器模式主要作用是解耦容器代码和遍历代码, 这也印证了前面多次讲过的应用设计模式的主要目的是解耦.
上一节课中讲解的内容都比较基础, 今天来深挖一下, 如果在使用迭代器遍历集合的同时增加, 删除集合中的元素, 会发生什么情况? 应该如何应对? 如何在遍历的同时安全地删除集合元素?
# 1.在遍历的同时增删集合元素会发生什么?
在通过迭代器来遍历集合元素的同时, 增加或者删除集合中的元素, 有可能会导致某个元素被重复遍历或遍历不到. 不过, 并不是所有情况下都会遍历出错, 有的时候也可以正常遍历, 所以这种行为称为结果不可预期行为或者未决行为, 也就是说, 运行结果到底是对还是错, 要视情况而定.
怎么理解呢? 通过一个例子来解释一下. 还是延续上一节课实现的 ArrayList 迭代器的例子. 为了方便查看, 相关的代码都重新拷贝到这里了.
public interface Iterator<E> {
boolean hasNext();
void next();
E currentItem();
}
public class ArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> arrayList;
public ArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor < arrayList.size();
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor >= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public interface List<E> {
Iterator iterator();
}
public class ArrayList<E> implements List<E> {
//...
public Iterator iterator() {
return new ArrayIterator(this);
}
//...
}
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
ArrayList 底层对应的是数组这种数据结构, 在执行完第 55 行代码的时候, 数组中存储的是 a, b, c, d 四个元素, 迭代器的游标 cursor 指向元素 a. 当执行完第 56 行代码的时候, 游标指向元素 b, 到这里都没有问题.
为了保持数组存储数据的连续性, 数组的删除操作会涉及元素的搬移. 当执行到第 57 行代码的时候, 从数组中将元素 a 删除掉, b, c, d 三个元素会依次往前搬移一位, 这就会导致游标本来指向元素 b, 现在变成了指向元素 c. 原本在执行完第 56 行代码之后, 还可以遍历到 b, c, d 三个元素, 但在执行完第 57 行代码之后, 只能遍历到 c, d 两个元素, b 遍历不到了.
对于上面的描述画了一张图, 可以对照着理解.

不过, 如果第 57 行代码删除的不是游标前面的元素(元素 a)以及游标所在位置的元素(元素 b), 而是游标后面的元素(元素 c 和 d), 这样就不会存在任何问题了, 不会存在某个元素遍历不到的情况了.
所以前面说, 在遍历的过程中删除集合元素, 结果是不可预期的, 有时候没问题(删除元素 c 或 d), 有时候就有问题(删除元素 a 或 b), 这个要视情况而定(到底删除的是哪个位置的元素), 就是这个意思.
在遍历的过程中删除集合元素, 有可能会导致某个元素遍历不到, 那在遍历的过程中添加集合元素, 会发生什么情况呢? 还是结合刚刚那个例子来讲解, 将上面的代码稍微改造一下, 把删除元素改为添加元素. 具体的代码如下所示:
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.add(0, "x");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
在执行完第 10 行代码之后, 数组中包含 a, b, c, d 四个元素, 游标指向 b 这个元素, 已经跳过了元素 a. 在执行完第 11 行代码之后, 将 x 插入到下标为 0 的位置, a, b, c, d 四个元素依次往后移动一位. 这个时候, 游标又重新指向了元素 a. 元素 a 被游标重复指向两次, 也就是说, 元素 a 存在被重复遍历的情况.
跟删除情况类似, 如果在游标的后面添加元素, 就不会存在任何问题. 所以在遍历的同时添加集合元素也是一种不可预期行为.
同样, 对于上面的添加元素的情况, 也画了一张图, 如下所示.

# 2.如何应对遍历时改变集合导致的未决行为?
当通过迭代器来遍历集合的时候, 增加, 删除集合元素会导致不可预期的遍历结果. 实际上, "不可预期" 比直接出错更加可怕, 有的时候运行正确, 有的时候运行错误, 一些隐藏很深, 很难 debug 的 bug 就是这么产生的. 那如何才能避免出现这种不可预期的运行结果呢?
有两种比较干脆利索的解决方案: 一种是遍历的时候不允许增删元素, 另一种是增删元素之后让遍历报错.
实际上, 第一种解决方案比较难实现, 要确定遍历开始和结束的时间点. 遍历开始的时间节点很容易获得. 可以把创建迭代器的时间点作为遍历开始的时间点. 但遍历结束的时间点该如何来确定呢?
你可能会说, 遍历到最后一个元素的时候就算结束呗. 但在实际的软件开发中, 每次使用迭代器来遍历元素, 并不一定非要把所有元素都遍历一遍. 如下所示找到一个值为 b 的元素就提前结束了遍历.
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
break;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
你可能还会说, 那可以在迭代器类中定义一个新的接口 finishIteration(), 主动告知容器迭代器使用完了, 你可以增删元素了, 示例代码如下所示. 但这就要求程序员在使用完迭代器之后要主动调用这个函数, 也增加了开发成本, 还很容易漏掉.
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
iterator.finishIteration();// 主动告知容器这个迭代器用完了
break;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
实际上, 第二种解决方法更加合理. Java 语言就是采用的这种解决方案, 增删元素之后, 让遍历报错. 接下来具体来看一下如何实现.
怎么确定在遍历时候, 集合有没有增删元素呢? 在 ArrayList 中定义一个成员变量 modCount, 记录集合被修改的次数, 集合每调用一次增加或删除元素的函数, 就会给 modCount 加 1. 当通过调用集合上的 iterator() 函数来创建迭代器的时候, 把 modCount 值传递给迭代器的 expectedModCount 成员变量, 之后每次调用迭代器上的 hasNext(), next(), currentItem() 函数, 都会检查集合上的 modCount 是否等于 expectedModCount, 也就是看, 在创建完迭代器之后, modCount 是否改变过.
如果两个值不相同, 那就说明集合存储的元素已经改变了, 要么增加了元素, 要么删除了元素, 之前创建的迭代器已经不能正确运行了, 再继续使用就会产生不可预期的结果, 所以选择 fail-fast 解决方式, 抛出运行时异常, 结束掉程序, 让程序员尽快修复这个因为不正确使用迭代器而产生的 bug.
上面的描述翻译成代码就是下面这样子.
public class ArrayIterator implements Iterator {
private int cursor;
private ArrayList arrayList;
private int expectedModCount;
public ArrayIterator(ArrayList arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
this.expectedModCount = arrayList.modCount;
}
@Override
public boolean hasNext() {
checkForComodification();
return cursor < arrayList.size();
}
@Override
public void next() {
checkForComodification();
cursor++;
}
@Override
public Object currentItem() {
checkForComodification();
return arrayList.get(cursor);
}
private void checkForComodification() {
if (arrayList.modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 代码示例
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
iterator.next();// 抛出ConcurrentModificationException异常
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.如何在遍历的同时安全地删除集合元素?
像 Java 语言, 迭代器类中除了前面提到的几个最基本的方法之外, 还定义了一个 remove() 方法, 能够在遍历集合的同时, 安全地删除集合中的元素. 不过, 需要说明的是, 它并没有提供添加元素的方法. 毕竟迭代器的主要作用是遍历, 添加元素放到迭代器里本身就不合适.
个人觉得, Java 迭代器中提供的 remove() 方法还是比较鸡肋的, 作用有限. 它只能删除游标指向的前一个元素, 而且一个 next() 函数之后, 只能跟着最多一个 remove() 操作, 多次调用 remove() 操作会报错. 还是通过一个例子来解释一下.
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
iterator.remove();
iterator.remove(); // 报错, 抛出IllegalStateException异常
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现在一块来看下, 为什么通过迭代器就能安全的删除集合中的元素呢? 源码之下无秘密. 来看下 remove() 函数是如何实现的, 代码如下所示. 稍微提醒一下, 在 Java 实现中, 迭代器类是容器类的内部类, 并且 next() 函数不仅将游标后移一位, 还会返回当前的元素.
public class ArrayList<E> {
transient Object[] elementData;
private int size;
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
在上面的代码实现中, 迭代器类新增了一个 lastRet 成员变量, 用来记录游标指向的前一个元素. 通过迭代器去删除这个元素的时候, 可以更新迭代器中的游标和 lastRet 值, 来保证不会因为删除元素而导致某个元素遍历不到. 如果通过容器来删除元素, 并且希望更新迭代器中的游标值来保证遍历不出错, 就要维护这个容器都创建了哪些迭代器, 每个迭代器是否还在使用等信息, 代码实现就变得比较复杂了.
重点回顾
在通过迭代器来遍历集合元素的同时, 增加或者删除集合中的元素, 有可能会导致某个元素被重复遍历或遍历不到. 不过, 并不是所有情况下都会遍历出错, 有的时候也可以正常遍历, 所以这种行为称为结果不可预期行为或者未决行为. 实际上, "不可预期" 比直接出错更加可怕, 有的时候运行正确, 有的时候运行错误, 一些隐藏很深, 很难 debug 的 bug 就是这么产生的.
有两种比较干脆利索的解决方案, 来避免出现这种不可预期的运行结果. 一种是遍历的时候不允许增删元素, 另一种是增删元素之后让遍历报错. 第一种解决方案比较难实现, 因为很难确定迭代器使用结束的时间点. 第二种解决方案更加合理. Java 语言就是采用的这种解决方案. 增删元素之后, 选择 fail-fast 解决方式, 让遍历操作直接抛出运行时异常.
像 Java 语言, 迭代器类中除了前面提到的几个最基本的方法之外, 还定义了一个 remove() 方法, 能够在遍历集合的同时, 安全地删除集合中的元素.
# 67-迭代器模式(下):如何设计实现一个支持"快照"功能的iterator?
今天再来看这样一个问题: 如何实现一个支持 "快照" 功能的迭代器? 这个问题算是对上一节的延伸思考, 为的是帮你加深对迭代器模式的理解, 也是对你分析, 解决问题的一种锻炼.
# 1.问题描述
先来介绍一下问题的背景: 如何实现一个支持 "快照" 功能的迭代器模式?
理解这个问题最关键的是理解 "快照" 两个字. 所谓 "快照", 指为容器创建迭代器的时候, 相当于给容器拍了一张快照(Snapshot). 之后即便增删容器中的元素, 快照中的元素并不会做相应的改动. 而迭代器遍历的对象是快照而非容器, 这样就避免了在使用迭代器遍历的过程中, 增删容器中的元素, 导致的不可预期的结果或者报错.
接下来, 举一个例子来解释一下上面这段话. 具体的代码如下所示. 容器 list 中初始存储了 3, 8, 2 三个元素. 尽管在创建迭代器 iter1 之后, 容器 list 删除了元素 3, 只剩下 8, 2 两个元素, 但通过 iter1 遍历的对象是快照, 而非容器 list 本身. 所以遍历的结果仍然是 3, 8, 2. 同理, iter2, iter3 也是在各自的快照上遍历, 输出的结果如代码中注释所示.
List<Integer> list = new ArrayList<>();
list.add(3);
list.add(8);
list.add(2);
Iterator<Integer> iter1 = list.iterator();// snapshot: 3, 8, 2
list.remove(new Integer(2));// list: 3, 8
Iterator<Integer> iter2 = list.iterator();// snapshot: 3, 8
list.remove(new Integer(3));// list: 8
Iterator<Integer> iter3 = list.iterator();// snapshot: 3
// 输出结果: 3 8 2
while (iter1.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
// 输出结果: 3 8
while (iter2.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
// 输出结果: 8
while (iter3.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
如果由你来实现上面的功能, 你会如何来做呢? 下面是针对这个功能需求的骨架代码, 其中包含 ArrayList, SnapshotArrayIterator 两个类. 对于这两个类, 只定义了必须的几个关键接口, 完整的代码实现我并没有给出.
public ArrayList<E> implements List<E> {
// TODO: 成员变量, 私有函数等
@Override
public void add(E obj) {
// TODO
}
@Override
public void remove(E obj) {
// TODO
}
@Override
public Iterator<E> iterator() {
return new SnapshotArrayIterator(this);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SnapshotArrayIterator<E> implements Iterator<E> {
// TODO: 成员变量, 私有函数等
@Override
public boolean hasNext() {
// TODO
}
@Override
public E next() { // 返回当前元素, 并且游标后移一位
// TODO
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 2.解决方案一
先来看最简单的一种解决办法. 在迭代器类中定义一个成员变量 snapshot 来存储快照. 每当创建迭代器的时候, 都拷贝一份容器中的元素到快照中, 后续的遍历操作都基于这个迭代器自己持有的快照来进行. 具体的代码实现如下所示:
public class SnapshotArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> snapshot;
public SnapshotArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.snapshot = new ArrayList<>();
this.snapshot.addAll(arrayList);
}
@Override
public boolean hasNext() {
return cursor < snapshot.size();
}
@Override
public E next() {
E currentItem = snapshot.get(cursor);
cursor++;
return currentItem;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这个解决方案虽然简单, 但代价也有点高. 每次创建迭代器的时候, 都要拷贝一份数据到快照中, 会增加内存的消耗. 如果一个容器同时有多个迭代器在遍历元素, 就会导致数据在内存中重复存储多份. 不过庆幸的是, Java 中的拷贝属于浅拷贝, 也就是说, 容器中的对象并非真的拷贝了多份, 而只是拷贝了对象的引用而已.
那有没有什么方法, 既可以支持快照, 又不需要拷贝容器呢?
# 3.解决方案二
再来看第二种解决方案.
可以在容器中, 为每个元素保存两个时间戳, 一个是添加时间戳 addTimestamp, 一个是删除时间戳 delTimestamp. 当元素被加入到集合中的时候, 将 addTimestamp 设置为当前时间, 将 delTimestamp 设置成最大长整型值(Long.MAX_VALUE). 当元素被删除时, 将 delTimestamp 更新为当前时间, 表示已经被删除.
注意, 这里只是标记删除, 而非真正将它从容器中删除.
同时, 每个迭代器也保存一个迭代器创建时间戳 snapshotTimestamp, 也就是迭代器对应的快照的创建时间戳. 当使用迭代器来遍历容器的时候, 只有满足 addTimestamp < snapshotTimestamp < delTimestamp 的元素, 才是属于这个迭代器的快照.
如果元素的 addTimestamp > snapshotTimestamp, 说明元素在创建了迭代器之后才加入的, 不属于这个迭代器的快照; 如果元素的 delTimestamp < snapshotTimestamp, 说明元素在创建迭代器之前就被删除掉了, 也不属于这个迭代器的快照.
这样就在不拷贝容器的情况下, 在容器本身上借助时间戳实现了快照功能. 具体的代码实现如下所示. 注意, 这里没有考虑 ArrayList 的扩容问题, 感兴趣的话, 你可以自己完善一下.
public class ArrayList<E> implements List<E> {
private static final int DEFAULT_CAPACITY = 10;
private int actualSize; // 不包含标记删除元素
private int totalSize; // 包含标记删除元素
private Object[] elements;
private long[] addTimestamps;
private long[] delTimestamps;
public ArrayList() {
this.elements = new Object[DEFAULT_CAPACITY];
this.addTimestamps = new long[DEFAULT_CAPACITY];
this.delTimestamps = new long[DEFAULT_CAPACITY];
this.totalSize = 0;
this.actualSize = 0;
}
@Override
public void add(E obj) {
elements[totalSize] = obj;
addTimestamps[totalSize] = System.currentTimeMillis();
delTimestamps[totalSize] = Long.MAX_VALUE;
totalSize++;
actualSize++;
}
@Override
public void remove(E obj) {
for (int i = 0; i < totalSize; ++i) {
if (elements[i].equals(obj)) {
delTimestamps[i] = System.currentTimeMillis();
actualSize--;
}
}
}
public int actualSize() {
return this.actualSize;
}
public int totalSize() {
return this.totalSize;
}
public E get(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return (E)elements[i];
}
public long getAddTimestamp(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return addTimestamps[i];
}
public long getDelTimestamp(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return delTimestamps[i];
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class SnapshotArrayIterator<E> implements Iterator<E> {
private long snapshotTimestamp;
private int cursorInAll; // 在整个容器中的下标, 而非快照中的下标
private int leftCount; // 快照中还有几个元素未被遍历
private ArrayList<E> arrayList;
public SnapshotArrayIterator(ArrayList<E> arrayList) {
this.snapshotTimestamp = System.currentTimeMillis();
this.cursorInAll = 0;
this.leftCount = arrayList.actualSize();;
this.arrayList = arrayList;
justNext(); // 先跳到这个迭代器快照的第一个元素
}
@Override
public boolean hasNext() {
return this.leftCount >= 0; // 注意是>=, 而非>
}
@Override
public E next() {
E currentItem = arrayList.get(cursorInAll);
justNext();
return currentItem;
}
private void justNext() {
while (cursorInAll < arrayList.totalSize()) {
long addTimestamp = arrayList.getAddTimestamp(cursorInAll);
long delTimestamp = arrayList.getDelTimestamp(cursorInAll);
if (snapshotTimestamp > addTimestamp && snapshotTimestamp < delTimestamp) {
leftCount--;
break;
}
cursorInAll++;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
实际上, 上面的解决方案相当于解决了一个问题, 又引入了另外一个问题. ArrayList 底层依赖数组这种数据结构, 原本可以支持快速的随机访问, 在 O(1) 时间复杂度内获取下标为 i 的元素, 但现在, 删除数据并非真正的删除, 只是通过时间戳来标记删除, 这就导致无法支持按照下标快速随机访问了.
现在来看怎么解决这个问题: 让容器既支持快照遍历, 又支持随机访问?
解决的方法也不难, 稍微提示一下. 可以在 ArrayList 中存储两个数组. 一个支持标记删除的, 用来实现快照遍历功能; 一个不支持标记删除的(也就是将要删除的数据直接从数组中移除), 用来支持随机访问. 对应的代码这里就不给出了, 感兴趣的话可以自己实现一下.
重点回顾
今天讲了如何实现一个支持 "快照" 功能的迭代器. 其实这个问题本身并不是学习的重点, 因为在真实的项目开发中, 几乎不会遇到这样的需求. 所以基于今天的内容我不想做过多的总结. 这里说一说, 为什么要来讲今天的内容呢?
实际上, 学习本节课的内容, 如果你只是从前往后看一遍, 看懂就觉得 ok 了, 那收获几乎是零. 一个好学习方法是, 把它当作一个思考题或者面试题, 在看讲解之前, 自己主动思考如何解决, 并且把解决方案用代码实现一遍, 然后再来看跟我的讲解有哪些区别. 这个过程对你分析问题, 解决问题的能力的锻炼, 代码设计能力, 编码能力的锻炼, 才是最有价值的, 才是这篇文章的意义所在. 所谓 "知识是死的, 能力才是活的" 就是这个道理.
# 68-访问者模式(上):手把手带你还原访问者模式诞生的思维过程
前面讲到, 大部分设计模式的原理和实现都很简单, 不过也有例外, 比如今天要讲的访问者模式. 它可以算是 23 种经典设计模式中最难理解的几个之一. 因为它难理解, 难实现, 应用它会导致代码的可读性, 可维护性变差, 所以, 访问者模式在实际的软件开发中很少被用到, 在没有特别必要的情况下, 建议不要使用访问者模式.
尽管如此, 为了让你以后读到应用了访问者模式的代码的时候, 能一眼就能看出代码的设计意图, 同时为了整个专栏内容的完整性, 还是有必要讲一讲这个模式. 除此之外, 为了最大化学习效果, 今天不只是单纯地讲解原理和实现, 更重要的是, 会手把手带你还原访问者模式诞生的思维过程, 让你切身感受到创造一种新的设计模式出来并不是件难事.
# 1.带你"发明"访问者模式
假设从网站上爬取了很多资源文件, 它们的格式有三种: PDF, PPT, Word. 现在要开发一个工具来处理这批资源文件. 这个工具的其中一个功能是, 把这些资源文件中的文本内容抽取出来放到 txt 文件中. 如果让你来实现, 你会怎么来做呢?
实现这个功能并不难, 不同的人有不同的写法, 将其中一种代码实现方式贴在这里. 其中, ResourceFile 是一个抽象类, 包含一个抽象函数 extract2txt() . PdfFile, PPTFile, WordFile 都继承 ResourceFile 类, 并且重写了 extract2txt() 函数. 在 ToolApplication 中, 可以利用多态特性, 根据对象的实际类型, 来决定执行哪个方法.
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract void extract2txt();
}
2
3
4
5
6
7
8
9
public class PPTFile extends ResourceFile {
public PPTFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
// ...省略一大坨从PPT中抽取文本的代码...
// ...将抽取出来的文本保存在跟filePath同名的.txt文件中...
System.out.println("Extract PPT.");
}
}
2
3
4
5
6
7
8
9
10
11
12
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println("Extract PDF.");
}
}
2
3
4
5
6
7
8
9
10
11
public class WordFile extends ResourceFile {
public WordFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println("Extract WORD.");
}
}
2
3
4
5
6
7
8
9
10
11
// 运行结果是:
// Extract PDF.
// Extract WORD.
// Extract PPT.
public class ToolApplication {
public static void main(String[] args) {
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.extract2txt();
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
// ...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
如果工具的功能不停地扩展, 不仅要能抽取文本内容, 还要支持压缩, 提取文件元信息(文件名, 大小, 更新时间等等)构建索引等一系列的功能, 那如果继续按照上面的实现思路, 就会存在这样几个问题:
- 违背开闭原则, 添加一个新的功能, 所有类的代码都要修改;
- 虽然功能增多, 每个类的代码都不断膨胀, 可读性和可维护性都变差了;
- 把所有比较上层的业务逻辑都耦合到 PdfFile, PPTFile, WordFile 类中, 导致这些类的职责不够单一, 变成了大杂烩.
针对上面的问题, 常用的解决方法就是拆分解耦, 把业务操作跟具体的数据结构解耦, 设计成独立的类. 这里按照访问者模式的演进思路来对上面的代码进行重构. 重构之后的代码如下所示.
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
}
2
3
4
5
6
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
//...
}
//...PPTFile, WordFile代码省略...
2
3
4
5
6
7
8
public class Extractor {
public void extract2txt(PPTFile pptFile) {
//...
System.out.println("Extract PPT.");
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println("Extract PDF.");
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println("Extract WORD.");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
extractor.extract2txt(resourceFile);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
// ...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这其中最关键的一点设计是, 把抽取文本内容的操作, 设计成了三个重载函数. 函数重载是 Java, C++ 这类面向对象编程语言中常见的语法机制. 所谓重载函数是指, 在同一类中函数名相同, 参数不同的一组函数.
不过, 如果你足够细心, 就会发现, 上面的代码是编译通过不了的, 第 37 行会报错. 这是为什么呢?
多态是一种动态绑定, 可以在运行时获取对象的实际类型, 来运行实际类型对应的方法. 而函数重载是一种静态绑定, 在编译时并不能获取对象的实际类型, 而是根据声明类型执行声明类型对应的方法.
在上面代码的第 35~38 行中, resourceFiles 包含的对象的声明类型都是 ResourceFile, 而并没有在 Extractor 类中定义参数类型是 ResourceFile 的 extract2txt() 重载函数, 所以在编译阶段就通过不了, 更别说在运行时根据对象的实际类型执行不同的重载函数了. 那如何解决这个问题呢?
解决的办法稍微有点难理解, 先来看代码, 然后再来慢慢解释.
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
//...
}
//...PPTFile, WordFile跟PdfFile类似, 这里就省略了...
//...Extractor代码不变...
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
在执行第 30 行的时候, 根据多态特性, 程序会调用实际类型的 accept 函数, 比如 PdfFile 的 accept 函数, 也就是第 16 行代码. 而 16 行代码中的 this 类型是 PdfFile 的, 在编译的时候就确定了, 所以会调用 extractor 的 extract2txt(PdfFile pdfFile) 这个重载函数. 这个实现思路是不是很有技巧? 这是理解访问者模式的关键所在, 也是之前所说的访问者模式不好理解的原因.
现在, 如果要继续添加新的功能, 比如前面提到的压缩功能, 根据不同的文件类型, 使用不同的压缩算法来压缩资源文件, 那我们该如何实现呢? 需要实现一个类似 Extractor 类的新类 Compressor 类, 在其中定义三个重载函数, 实现对不同类型资源文件的压缩. 除此之外, 还要在每个资源文件类中定义新的 accept 重载函数. 具体的代码如下所示:
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
abstract public void accept(Compressor compressor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
@Override
public void accept(Compressor compressor) {
compressor.compress(this);
}
//...
}
//...PPTFile, WordFile跟PdfFile类似, 这里就省略了...
//...Extractor代码不变
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
Compressor compressor = new Compressor();
for(ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(compressor);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
上面代码还存在一些问题, 添加一个新的业务, 还是需要修改每个资源文件类, 违反了开闭原则. 针对这个问题, 抽象出来一个 Visitor 接口, 包含是三个命名非常通用的 visit() 重载函数, 分别处理三种不同类型的资源文件. 具体做什么业务处理, 由实现这个 Visitor 接口的具体的类来决定, 比如 Extractor 负责抽取文本内容, Compressor 负责压缩. 当新添加一个业务功能的时候, 资源文件类不需要做任何修改, 只需要修改 ToolApplication 的代码就可以了.
按照这个思路可以对代码进行重构, 重构之后的代码如下所示:
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Visitor vistor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
//...
}
//...PPTFile, WordFile跟PdfFile类似, 这里就省略了...
public interface Visitor {
void visit(PdfFile pdfFile);
void visit(PPTFile pdfFile);
void visit(WordFile pdfFile);
}
public class Extractor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println("Extract PPT.");
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println("Extract PDF.");
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println("Extract WORD.");
}
}
public class Compressor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println("Compress PPT.");
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println("Compress PDF.");
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println("Compress WORD.");
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
Compressor compressor = new Compressor();
for(ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(compressor);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# 2.重新来看访问者模式
刚刚一步一步还原了访问者模式诞生的思维过程, 现在回过头来总结一下, 这个模式的原理和代码实现.
访问者者模式的英文翻译是 Visitor Design Pattern. 在 GoF 的《设计模式》一书中, 它是这么定义的:
Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.
翻译成中文就是: 允许一个或者多个操作应用到一组对象上, 解耦操作和对象本身.
定义比较简单, 结合前面的例子不难理解, 就不过多解释了. 对于访问者模式的代码实现, 实际上, 在上面例子中, 经过层层重构之后的最终代码, 就是标准的访问者模式的实现代码. 这里总结了一张类图, 可以对照着前面的例子代码一块儿来看一下.

最后再来看下, 访问者模式的应用场景.
一般来说, 访问者模式针对的是一组类型不同的对象(PdfFile, PPTFile, WordFile). 不过, 尽管这组对象的类型是不同的, 但它们继承相同的父类(ResourceFile)或者实现相同的接口. 在不同的应用场景下, 需要对这组对象进行一系列不相关的业务操作(抽取文本, 压缩等), 但为了避免不断添加功能导致类(PdfFile, PPTFile, WordFile)不断膨胀, 职责越来越不单一, 以及避免频繁地添加功能导致的频繁代码修改, 使用访问者模式, 将对象与操作解耦, 将这些业务操作抽离出来, 定义在独立细分的访问者类(Extractor, Compressor)中.
重点回顾
访问者模式允许一个或者多个操作应用到一组对象上, 设计意图是解耦操作和对象本身, 保持类职责单一, 满足开闭原则以及应对代码的复杂性.
对于访问者模式, 学习的主要难点在代码实现. 而代码实现比较复杂的主要原因是, 函数重载在大部分面向对象编程语言中是静态绑定的. 也就是说, 调用类的哪个重载函数, 是在编译期间, 由参数的声明类型决定的, 而非运行时, 根据参数的实际类型决定的.
正是因为代码实现难理解, 所以在项目中应用这种模式, 会导致代码的可读性比较差. 所以, 除非不得已, 不要使用这种模式.
# 69-访问者模式(下):为什么支持双分派的语言不需要访问者模式?
上一节学习了访问者模式的原理和实现, 并且还原了访问者模式诞生的思维过程. 总体上来讲, 这个模式的代码实现比较难, 所以应用场景并不多. 从应用开发的角度来说, 它的确不是学习的重点.
不过前面反复说过, 学习这个专栏, 并不只是让你掌握知识, 更重要的是锻炼分析, 解决问题的能力, 锻炼逻辑思维能力, 所以今天继续把访问者模式作为引子, 一块讨论一下这样两个问题:
- 为什么支持双分派的语言不需要访问者模式呢?
- 除了访问者模式, 上一节的例子还有其他实现方案吗?
# 1.为什么支持双分派的语言不需要访问者模式?
实际上, 讲到访问者模式, 大部分书籍或者资料都会讲到 Double Dispatch, 中文翻译为双分派. 虽然学习访问者模式, 并不用非得理解这个概念, 前面的讲解就没有提到它, 但为了让你在查看其它书籍或者资料的时候, 不会卡在这个概念上, 我觉得有必要在这里讲一下.
除此之外, 学习 Double Dispatch 还能加深你对访问者模式的理解, 而且能一并帮你搞清楚今天文章标题中的这个问题: 为什么支持双分派的语言就不需要访问者模式?
既然有 Double Dispatch, 对应的就有 Single Dispatch. 所谓 Single Dispatch, 指的是执行哪个对象的方法, 根据对象的运行时类型来决定; 执行对象的哪个方法, 根据方法参数的编译时类型来决定. 所谓 Double Dispatch, 指的是执行哪个对象的方法, 根据对象的运行时类型来决定; 执行对象的哪个方法, 根据方法参数的运行时类型来决定.
如何理解 "Dispatch" 这个单词呢? 在面向对象编程语言中, 可以把方法调用理解为一种消息传递, 也就是 "Dispatch". 一个对象调用另一个对象的方法, 就相当于给它发送一条消息. 这条消息起码要包含对象名, 方法名, 方法参数.
如何理解 "Single", "Double" 这两个单词呢? "Single", "Double" 指的是执行哪个对象的哪个方法, 跟几个因素的运行时类型有关. 进一步解释一下. Single Dispatch 之所以称为 "Single", 是因为执行哪个对象的哪个方法, 只跟 "对象" 的运行时类型有关. Double Dispatch 之所以称 为"Double", 是因为执行哪个对象的哪个方法, 跟 "对象" 和 "方法参数" 两者的运行时类型有关.
具体到编程语言的语法机制, Single Dispatch 和 Double Dispatch 跟多态和函数重载直接相关. 当前主流的面向对象编程语言(比如, Java, C++, C#)都只支持 Single Dispatch, 不支持 Double Dispatch.
接下来拿 Java 语言来举例说明一下.
Java 支持多态特性, 代码可以在运行时获得对象的实际类型(也就是前面提到的运行时类型), 然后根据实际类型决定调用哪个方法. 尽管 Java 支持函数重载, 但 Java 设计的函数重载的语法规则是, 并不是在运行时, 根据传递进函数的参数的实际类型, 来决定调用哪个重载函数, 而是在编译时, 根据传递进函数的参数的声明类型(也就是前面提到的编译时类型), 来决定调用哪个重载函数. 也就是说, 具体执行哪个对象的哪个方法, 只跟对象的运行时类型有关, 跟参数的运行时类型无关. 所以, Java 语言只支持 Single Dispatch.
这么说比较抽象, 举个例子来具体说明一下, 代码如下所示:
public class ParentClass {
public void f() {
System.out.println("I am ParentClass's f().");
}
}
public class ChildClass extends ParentClass {
public void f() {
System.out.println("I am ChildClass's f().");
}
}
public class SingleDispatchClass {
public void polymorphismFunction(ParentClass p) {
p.f();
}
public void overloadFunction(ParentClass p) {
System.out.println("I am overloadFunction(ParentClass p).");
}
public void overloadFunction(ChildClass c) {
System.out.println("I am overloadFunction(ChildClass c).");
}
}
public class DemoMain {
public static void main(String[] args) {
SingleDispatchClass demo = new SingleDispatchClass();
ParentClass p = new ChildClass();
demo.polymorphismFunction(p);// 执行哪个对象的方法, 由对象的实际类型决定
demo.overloadFunction(p); // 执行对象的哪个方法, 由参数对象的声明类型决定
}
}
// 代码执行结果:
I am ChildClass's f().
I am overloadFunction(ParentClass p).
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
在上面的代码中, 第 31 行代码的 polymorphismFunction() 函数, 执行 p 的实际类型的 f() 函数, 也就是 ChildClass 的 f() 函数. 第 32 行代码的 overloadFunction() 函数, 匹配的是重载函数中的 overloadFunction(ParentClass p), 也就是根据 p 的声明类型来决定匹配哪个重载函数.
假设 Java 语言支持 Double Dispatch, 那下面的代码(摘抄自上节课中第二段代码)中的第 37 行就不会报错. 代码会在运行时, 根据参数(resourceFile)的实际类型(PdfFile, PPTFile, WordFile), 来决定使用 extract2txt 的三个重载函数中的哪一个. 那下面的代码实现就能正常运行了, 也就不需要访问者模式了. 这也回答了为什么支持 Double Dispatch 的语言不需要访问者模式.
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
//...
}
//...PPTFile, WordFile代码省略...
public class Extractor {
public void extract2txt(PPTFile pptFile) {
//...
System.out.println("Extract PPT.");
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println("Extract PDF.");
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println("Extract WORD.");
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
extractor.extract2txt(resourceFile);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 2.除了访问者模式,上一节的例子还有其他实现方案吗?
上节通过一个例子展示了, 访问者模式是如何一步一步设计出来的. 这里再一块回顾一下那个例子. 从网站上爬取了很多资源文件, 它们的格式有三种: PDF, PPT, Word. 要开发一个工具来处理这批资源文件, 这其中就包含抽取文本内容, 压缩资源文件, 提取文件元信息等.
实际上, 开发这个工具有很多种代码设计和实现思路. 为了讲解访问者模式, 上节选择了用访问者模式来实现. 实际上, 还有其他的实现方法, 比如还可以利用工厂模式来实现, 定义一个包含 extract2txt() 接口函数的 Extractor 接口. PdfExtractor, PPTExtractor, WordExtractor 类实现 Extractor 接口, 并且在各自的 extract2txt() 函数中, 分别实现 Pdf, PPT, Word 格式文件的文本内容抽取. ExtractorFactory 工厂类根据不同的文件类型, 返回不同的 Extractor.
这个实现思路其实更加简单, 直接看代码.
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract ResourceFileType getType();
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public ResourceFileType getType() {
return ResourceFileType.PDF;
}
//...
}
//...PPTFile/WordFile跟PdfFile代码结构类似, 此处省略...
public interface Extractor {
void extract2txt(ResourceFile resourceFile);
}
public class PdfExtractor implements Extractor {
@Override
public void extract2txt(ResourceFile resourceFile) {
//...
}
}
//...PPTExtractor/WordExtractor跟PdfExtractor代码结构类似, 此处省略...
public class ExtractorFactory {
private static final Map<ResourceFileType, Extractor> extractors = new HashMap<>();
static {
extractors.put(ResourceFileType.PDF, new PdfExtractor());
extractors.put(ResourceFileType.PPT, new PPTExtractor());
extractors.put(ResourceFileType.WORD, new WordExtractor());
}
public static Extractor getExtractor(ResourceFileType type) {
return extractors.get(type);
}
}
public class ToolApplication {
public static void main(String[] args) {
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
Extractor extractor = ExtractorFactory.getExtractor(resourceFile.getType());
extractor.extract2txt(resourceFile);
}
}
private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
当需要添加新的功能的时候, 比如压缩资源文件, 类似抽取文本内容功能的代码实现, 只需要添加一个 Compressor 接口, PdfCompressor, PPTCompressor, WordCompressor 三个实现类, 以及创建它们的 CompressorFactory 工厂类即可. 唯一需要修改的只有最上层的 ToolApplication 类. 基本上符合 "对扩展开放, 对修改关闭" 的设计原则.
对于资源文件处理工具这个例子, 如果工具提供的功能并不是非常多, 只有几个而已, 那更推荐使用工厂模式的实现方式, 毕竟代码更加清晰, 易懂. 相反, 如果工具提供非常多的功能, 比如有十几个, 那更推荐使用访问者模式, 因为访问者模式需要定义的类要比工厂模式的实现方式少很多, 类太多也会影响到代码的可维护性.
重点回顾
总体上来讲, 访问者模式难以理解, 应用场景有限, 不是特别必需, 不建议在项目中使用它. 所以, 对于上节处理资源文件的例子, 更推荐使用工厂模式来设计和实现.
除此之外, 今天重点讲解了 Double Dispatch. 在面向对象编程语言中, 方法调用可以理解为一种消息传递(Dispatch). 一个对象调用另一个对象的方法, 就相当于给它发送一条消息, 这条消息起码要包含对象名, 方法名和方法参数.
所谓 Single Dispatch, 指的是执行哪个对象的方法, 根据对象的运行时类型来决定; 执行对象的哪个方法, 根据方法参数的编译时类型来决定. 所谓 Double Dispatch, 指的是执行哪个对象的方法, 根据对象的运行时类型来决定; 执行对象的哪个方法, 根据方法参数的运行时类型来决定.
具体到编程语言的语法机制, Single Dispatch 和 Double Dispatch 跟多态和函数重载直接相关. 当前主流的面向对象编程语言(比如, Java, C++, C#)都只支持 Single Dispatch, 不支持 Double Dispatch.
# 70-备忘录模式:对于大对象的备份和恢复,如何优化内存和时间的消耗?
今天学习另外一种行为型模式, 备忘录模式. 这个模式理解, 掌握起来不难, 代码实现比较灵活, 应用场景也比较明确和有限, 主要是用来防丢失, 撤销, 恢复等.
# 1.备忘录模式的原理与实现
备忘录模式, 也叫快照(Snapshot)模式, 英文翻译是 Memento Design Pattern. 在 GoF 的《设计模式》一书中, 备忘录模式是这么定义的:
Captures and externalizes an object’s internal state so that it can be restored later, all without violating encapsulation.
翻译成中文就是: 在不违背封装原则的前提下, 捕获一个对象的内部状态, 并在该对象之外保存这个状态, 以便之后恢复对象为先前的状态.
在我看来, 这个模式的定义主要表达了两部分内容. 一部分是, 存储副本以便后期恢复. 这一部分很好理解. 另一部分是, 要在不违背封装原则的前提下, 进行对象的备份和恢复. 这部分不太好理解. 接下来就结合一个例子来解释一下, 特别带你搞清楚这两个问题:
- 为什么存储和恢复副本会违背封装原则?
- 备忘录模式是如何做到不违背封装原则的?
假设有这样一道面试题, 希望编写一个小程序, 可以接收命令行的输入. 用户输入文本时, 程序将其追加存储在内存文本中; 用户输入 ":list", 程序在命令行中输出内存文本的内容; 用户输入 ":undo", 程序会撤销上一次输入的文本, 也就是从内存文本中将上次输入的文本删除掉.
举个小例子来解释一下这个需求, 如下所示:
>hello
>:list
hello
>world
>:list
helloworld
>:undo
>:list
hello
2
3
4
5
6
7
8
9
怎么来编程实现呢? 整体上来讲, 这个小程序实现起来并不复杂. 我写了一种实现思路, 如下所示:
public class InputText {
private StringBuilder text = new StringBuilder();
public String getText() {
return text.toString();
}
public void append(String input) {
text.append(input);
}
public void setText(String text) {
this.text.replace(0, this.text.length(), text);
}
}
public class SnapshotHolder {
private Stack<InputText> snapshots = new Stack<>();
public InputText popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(InputText inputText) {
InputText deepClonedInputText = new InputText();
deepClonedInputText.setText(inputText.getText());
snapshots.push(deepClonedInputText);
}
}
public class ApplicationMain {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotsHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(":list")) {
System.out.println(inputText.toString());
} else if (input.equals(":undo")) {
InputText snapshot = snapshotsHolder.popSnapshot();
inputText.setText(snapshot.getText());
} else {
snapshotsHolder.pushSnapshot(inputText);
inputText.append(input);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
实际上, 备忘录模式的实现很灵活, 也没有很固定的实现方式, 在不同的业务需求, 不同编程语言下, 代码实现可能都不大一样. 上面的代码基本上已经实现了最基本的备忘录的功能. 但如果深究一下的话, 还有一些问题要解决, 那就是前面定义中提到的第二点: 要在不违背封装原则的前提下, 进行对象的备份和恢复. 而上面的代码并不满足这一点, 主要体现在下面两方面:
- 第一, 为了能用快照恢复 InputText 对象, 在 InputText 类中定义了 setText() 函数, 但这个函数有可能会被其他业务使用, 所以暴露不应该暴露的函数违背了封装原则;
- 第二, 快照本身是不可变的, 理论上讲, 不应该包含任何 set() 等修改内部状态的函数, 但在上面的代码实现中, "快照" 这个业务模型复用了 InputText 类的定义, 而 InputText 类本身有一系列修改内部状态的函数, 所以用 InputText 类来表示快照违背了封装原则.
针对以上问题, 对代码做两点修改. 其一, 定义一个独立的类(Snapshot 类)来表示快照, 而不是复用 InputText 类. 这个类只暴露 get() 方法, 没有 set() 等任何修改内部状态的方法. 其二, 在 InputText 类中, 把 setText() 方法重命名为 restoreSnapshot() 方法, 用意更加明确, 只用来恢复对象.
按照这个思路, 对代码进行重构. 重构之后的代码如下所示:
public class InputText {
private StringBuilder text = new StringBuilder();
public String getText() {
return text.toString();
}
public void append(String input) {
text.append(input);
}
public Snapshot createSnapshot() {
return new Snapshot(text.toString());
}
public void restoreSnapshot(Snapshot snapshot) {
this.text.replace(0, this.text.length(), snapshot.getText());
}
}
public class Snapshot {
private String text;
public Snapshot(String text) {
this.text = text;
}
public String getText() {
return this.text;
}
}
public class SnapshotHolder {
private Stack<Snapshot> snapshots = new Stack<>();
public Snapshot popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(Snapshot snapshot) {
snapshots.push(snapshot);
}
}
public class ApplicationMain {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotsHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(":list")) {
System.out.println(inputText.toString());
} else if (input.equals(":undo")) {
Snapshot snapshot = snapshotsHolder.popSnapshot();
inputText.restoreSnapshot(snapshot);
} else {
snapshotsHolder.pushSnapshot(inputText.createSnapshot());
inputText.append(input);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
实际上, 上面的代码实现就是典型的备忘录模式的代码实现, 也是很多书籍(包括 GoF 的《设计模式》)中给出的实现方法.
除了备忘录模式, 还有一个跟它很类似的概念, "备份", 它在平时的开发中更常听到. 那备忘录模式跟 "备份" 有什么区别和联系呢? 实际上, 这两者的应用场景很类似, 都应用在防丢失, 恢复, 撤销等场景中. 它们的区别在于, 备忘录模式更侧重于代码的设计和实现, 备份更侧重架构设计或产品设计.
# 2.如何优化内存和时间消耗?
前面只是简单介绍了备忘录模式的原理和经典实现, 现在再继续深挖一下. 如果要备份的对象数据比较大, 备份频率又比较高, 那快照占用的内存会比较大, 备份和恢复的耗时会比较长. 这个问题该如何解决呢?
不同的应用场景下有不同的解决方法. 比如前面举的那个例子, 应用场景是利用备忘录来实现撤销操作, 而且仅仅支持顺序撤销, 也就是说, 每次操作只能撤销上一次的输入, 不能跳过上次输入撤销之前的输入. 在具有这样特点的应用场景下, 为了节省内存, 不需要在快照中存储完整的文本, 只需要记录少许信息, 比如在获取快照当下的文本长度, 用这个值结合 InputText 类对象存储的文本来做撤销操作.
再举一个例子. 假设每当有数据改动, 都需要生成一个备份, 以备之后恢复. 如果需要备份的数据很大, 这样高频率的备份, 不管是对存储(内存或者硬盘)的消耗, 还是对时间的消耗, 都可能是无法接受的. 想要解决这个问题, 一般会采用 "低频率全量备份" 和 "高频率增量备份" 相结合的方法.
全量备份就不用讲了, 它跟上面的例子类似, 就是把所有的数据 "拍个快照" 保存下来. 所谓 "增量备份", 指的是记录每次操作或数据变动. 当需要恢复到某一时间点的备份的时候, 如果这一时间点有做全量备份, 直接拿来恢复就可以了. 如果这一时间点没有对应的全量备份, 就先找到最近的一次全量备份, 然后用它来恢复, 之后执行此次全量备份跟这一时间点之间的所有增量备份, 也就是对应的操作或者数据变动. 这样就能减少全量备份的数量和频率, 减少对时间, 内存的消耗.
重点回顾
备忘录模式也叫快照模式, 具体来说, 就是在不违背封装原则的前提下, 捕获一个对象的内部状态, 并在该对象之外保存这个状态, 以便之后恢复对象为先前的状态. 这个模式的定义表达了两部分内容: 一部分是, 存储副本以便后期恢复; 另一部分是, 要在不违背封装原则的前提下, 进行对象的备份和恢复.
备忘录模式的应用场景也比较明确和有限, 主要是用来防丢失, 撤销, 恢复等. 它跟平时常说的 "备份" 很相似. 两者的主要区别在于, 备忘录模式更侧重于代码的设计和实现, 备份更侧重架构设计或产品设计.
对于大对象的备份来说, 备份占用的存储空间会比较大, 备份和恢复的耗时会比较长. 针对这个问题, 不同的业务场景有不同的处理方式. 比如, 只备份必要的恢复信息, 结合最新的数据来恢复; 再比如, 全量备份和增量备份相结合, 低频全量备份, 高频增量备份, 两者结合来做恢复.
# 71-命令模式:如何利用命令模式实现一个手游后端架构?
现在只剩下 3 个模式还没有学习, 它们分别是: 命令模式, 解释器模式, 中介模式. 这 3 个模式使用频率低, 理解难度大, 只在非常特定的应用场景下才会用到, 所以不是学习的重点, 只需要稍微了解, 见了能认识就可以了.
今天来学习其中的命令模式. 在学习这个模式的过程中, 你可能会遇到的最大的疑惑是, 感觉命令模式没啥用, 是一种过度设计, 有更加简单的设计思路可以替代. 所以今天讲解的重点是这个模式的设计意图, 带你搞清楚到底什么情况下才真正需要使用它.
# 1.命令模式的原理解读
命令模式的英文翻译是 Command Design Pattern. 在 GoF 的《设计模式》一书中, 它是这么定义的:
The command pattern encapsulates a request as an object, thereby letting us parameterize other objects with different requests, queue or log requests, and support undoable operations.
翻译成中文就是下面这样. 为了帮助你理解, 对这个翻译稍微做了补充和解释, 也一起放在了下面的括号中.
命令模式将请求(命令)封装为一个对象, 这样可以使用不同的请求参数化其他对象(将不同请求依赖注入到其他对象), 并且能够支持请求(命令)的排队执行, 记录日志, 撤销等(附加控制)功能.
对于 GoF 给出的定义, 这里再进一步解读一下.
落实到编码实现, 命令模式用的最核心的实现手段, 是将函数封装成对象. C 语言支持函数指针, 可以把函数当作变量传递来传递去. 但在大部分编程语言中, 函数没法作为参数传递给其他函数, 也没法儿赋值给变量. 借助命令模式, 可以将函数封装成对象. 具体来说就是, 设计一个包含这个函数的类, 实例化一个对象传来传去, 这样就可以实现把函数像对象一样使用. 从实现的角度来说, 它类似之前讲过的回调.
当把函数封装成对象之后, 对象就可以存储下来, 方便控制执行. 所以, 命令模式的主要作用和应用场景, 是用来控制命令的执行, 比如, 异步, 延迟, 排队执行命令, 撤销重做命令, 存储命令, 给命令记录日志等等, 这才是命令模式能发挥独一无二作用的地方.
# 2.命令模式的实战讲解
上面的讲解比较偏理论, 比较不好理解, 这里再结合一个具体的例子来解释一下.
假设需要开发一个类似《天天酷跑》或者《QQ 卡丁车》这样的手游. 这种游戏本身的复杂度集中在客户端. 后端基本上只负责数据(比如积分, 生命值, 装备)的更新和查询, 所以后端逻辑相对于客户端来说, 要简单很多.
为了提高性能, 一般会把游戏中玩家的信息保存在内存中. 在游戏进行的过程中, 只更新内存中的数据, 游戏结束之后, 再将内存中的数据存档, 也就是持久化到数据库中. 为了降低实现的难度, 一般来说, 同一个游戏场景里的玩家, 会被分配到同一台服务上. 这样一个玩家拉取同一个游戏场景中的其他玩家的信息, 就不需要跨服务器去查找了, 实现起来就简单了很多.
一般来说, 游戏客户端和服务器之间的数据交互是比较频繁的, 所以为了节省网络连接建立的开销, 客户端和服务器之间一般采用长连接的方式来通信. 通信的格式有多种, 比如 Protocol Buffer, JSON, XML, 甚至可以自定义格式. 不管是什么格式, 客户端发送给服务器的请求, 一般都包括两部分内容: 指令和数据. 其中, 指令也可以叫作事件, 数据是执行这个指令所需的数据.
服务器在接收到客户端的请求之后, 会解析出指令和数据, 并且根据指令的不同, 执行不同的处理逻辑. 对于这样的一个业务场景, 一般有两种架构实现思路.
常用的一种实现思路是利用多线程. 一个线程接收请求, 接收到请求之后, 启动一个新的线程来处理请求. 具体点讲, 一般是通过一个主线程来接收客户端发来的请求. 每当接收到一个请求之后, 就从一个专门用来处理请求的线程池中, 捞出一个空闲线程来处理.
另一种实现思路是在一个线程内轮询接收请求和处理请求. 这种处理方式不太常见. 尽管它无法利用多线程多核处理的优势, 但是对于 IO 密集型的业务来说, 它避免了多线程不停切换对性能的损耗, 并且克服了多线程编程 Bug 比较难调试的缺点, 也算是手游后端服务器开发中比较常见的架构模式了.
接下来就重点讲一下第二种实现方式.
整个手游后端服务器轮询获取客户端发来的请求, 获取到请求之后, 借助命令模式, 把请求包含的数据和处理逻辑封装为命令对象, 并存储在内存队列中. 然后再从队列中取出一定数量的命令来执行. 执行完成之后, 再重新开始新的一轮轮询. 具体的示例代码如下所示, 可以结合着一块看下.
public interface Command {
void execute();
}
public class GotDiamondCommand implements Command {
// 省略成员变量
public GotDiamondCommand(/*数据*/) {
//...
}
@Override
public void execute() {
// 执行相应的逻辑
}
}
// GotStartCommand/HitObstacleCommand/ArchiveCommand类省略
public class GameApplication {
private static final int MAX_HANDLED_REQ_COUNT_PER_LOOP = 100;
private Queue<Command> queue = new LinkedList<>();
public void mainloop() {
while (true) {
List<Request> requests = new ArrayList<>();
// 省略从epoll或者select中获取数据, 并封装成Request的逻辑,
// 注意设置超时时间, 如果很长时间没有接收到请求, 就继续下面的逻辑处理.
for (Request request : requests) {
Event event = request.getEvent();
Command command = null;
if (event.equals(Event.GOT_DIAMOND)) {
command = new GotDiamondCommand(/*数据*/);
} else if (event.equals(Event.GOT_STAR)) {
command = new GotStartCommand(/*数据*/);
} else if (event.equals(Event.HIT_OBSTACLE)) {
command = new HitObstacleCommand(/*数据*/);
} else if (event.equals(Event.ARCHIVE)) {
command = new ArchiveCommand(/*数据*/);
} // ...一堆else if...
queue.add(command);
}
int handledCount = 0;
while (handledCount < MAX_HANDLED_REQ_COUNT_PER_LOOP) {
if (queue.isEmpty()) {
break;
}
Command command = queue.poll();
command.execute();
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 3.命令模式VS策略模式
看了刚才的讲解, 可能会觉得, 命令模式跟策略模式, 工厂模式非常相似啊, 那它们的区别在哪里呢? 不仅如此, 不止一个同学反映, 感觉学过的很多模式都很相似. 不知道你有没有类似的感觉呢?
实际上, 每个设计模式都应该由两部分组成: 第一部分是应用场景, 即这个模式可以解决哪类问题; 第二部分是解决方案, 即这个模式的设计思路和具体的代码实现. 不过, 代码实现并不是模式必须包含的. 如果单纯地只关注解决方案这一部分, 甚至只关注代码实现, 就会产生大部分模式看起来都很相似的错觉.
实际上, 设计模式之间的主要区别还是在于设计意图, 也就是应用场景. 单纯地看设计思路或者代码实现, 有些模式确实很相似, 比如策略模式和工厂模式.
之前讲策略模式的时候有讲到, 策略模式包含策略的定义, 创建和使用三部分, 从代码结构上来, 它非常像工厂模式. 它们的区别在于, 策略模式侧重 "策略" 或 "算法" 这个特定的应用场景, 用来解决根据运行时状态从一组策略中选择不同策略的问题, 而工厂模式侧重封装对象的创建过程, 这里的对象没有任何业务场景的限定, 可以是策略, 但也可以是其他东西. 从设计意图上来, 这两个模式完全是两回事儿.
有了刚刚的铺垫, 接下来再来看命令模式跟策略模式的区别. 你可能会觉得, 命令的执行逻辑也可以看作策略, 那它是不是就是策略模式了呢? 实际上, 这两者有一点细微的区别.
在策略模式中, 不同的策略具有相同的目的, 不同的实现, 互相之间可以替换. 比如, BubbleSort, SelectionSort 都是为了实现排序的, 只不过一个是用冒泡排序算法来实现的, 另一个是用选择排序算法来实现的. 而在命令模式中, 不同的命令具有不同的目的, 对应不同的处理逻辑, 并且互相之间不可替换.
重点回顾
命令模式在平时工作中并不常用, 稍微了解一下就可以. 今天重点讲解了它的设计意图, 也就是能解决什么问题.
落实到编码实现, 命令模式用到最核心的实现手段, 就是将函数封装成对象. 在大部分编程语言中, 函数是没法作为参数传递给其他函数的, 也没法赋值给变量. 借助命令模式, 可以将函数封装成对象, 这样就可以实现把函数像对象一样使用.
命令模式的主要作用和应用场景, 是用来控制命令的执行, 比如, 异步, 延迟, 排队执行命令, 撤销重做命令, 存储命令, 给命令记录日志等等, 这才是命令模式能发挥独一无二作用的地方.
# 72-解释器模式:如何设计实现一个自定义接口告警规则功能?
今天来学习解释器模式, 它用来描述如何构建一个简单的 "语言" 解释器. 比起命令模式, 解释器模式更加小众, 只在一些特定的领域会被用到, 比如编译器, 规则引擎, 正则表达式. 所以解释器模式也不是学习的重点, 稍微了解一下就可以了.
# 1.解释器模式的原理和实现
解释器模式的英文翻译是 Interpreter Design Pattern. 在 GoF 的《设计模式》一书中, 它是这样定义的:
Interpreter pattern is used to defines a grammatical representation for a language and provides an interpreter to deal with this grammar.
翻译成中文就是: 解释器模式为某个语言定义它的语法(或者叫文法)表示, 并定义一个解释器用来处理这个语法.
看了定义, 你估计会一头雾水, 因为这里面有很多平时开发中很少接触的概念, 比如 "语言", "语法", "解释器". 实际上, 这里的 "语言" 不仅仅指中, 英, 日, 法等各种语言. 从广义上来讲, 只要是能承载信息的载体, 都可以称之为 "语言", 比如, 古代的结绳记事, 盲文, 哑语, 摩斯密码等.
要想了解 "语言" 表达的信息, 就必须定义相应的语法规则. 这样, 书写者就可以根据语法规则来书写 "句子"(专业点的叫法应该是 "表达式"), 阅读者根据语法规则来阅读 "句子", 这样才能做到信息的正确传递. 而要讲的解释器模式, 其实就是用来实现根据语法规则解读 "句子" 的解释器.
为了更好地理解定义, 举个比较贴近生活的例子来解释一下.
实际上, 理解这个概念, 可以类比中英文翻译. 把英文翻译成中文是有一定规则的. 这个规则就是定义中的 "语法". 开发一个类似 Google Translate 这样的翻译器, 这个翻译器能够根据语法规则, 将输入的中文翻译成英文. 这里的翻译器就是解释器模式定义中的 "解释器".
刚刚翻译器这个例子比较贴近生活, 现在再举个更加贴近编程的例子.
假设定义了一个新的加减乘除计算 "语言", 语法规则如下:
- 运算符只包含加, 减, 乘, 除, 并且没有优先级的概念;
- 表达式(也就是前面提到的 "句子")中, 先书写数字, 后书写运算符, 空格隔开;
- 按照先后顺序, 取出两个数字和一个运算符计算结果, 结果重新放入数字的最头部位置, 循环上述过程, 直到只剩下一个数字, 这个数字就是表达式最终的计算结果.
举个例子来解释一下上面的语法规则.
比如 "8 3 2 4 - + *" 这样一个表达式, 按照上面的语法规则来处理, 取出数字 "8 3" 和 "-" 运算符, 计算得到 5, 于是表达式就变成了 "5 2 4 + *". 然后再取出 "5 2" 和 "+" 运算符, 计算得到 7, 表达式就变成了 "7 4 *". 最后取出 "7 4" 和 "*" 运算符, 最终得到的结果就是 28.
看懂了上面的语法规则, 将它用代码实现出来, 如下所示. 代码非常简单, 用户按照上面的规则书写表达式, 传递给 interpret() 函数, 就可以得到最终的计算结果.
public class ExpressionInterpreter {
private Deque<Long> numbers = new LinkedList<>();
public long interpret(String expression) {
String[] elements = expression.split(" ");
int length = elements.length;
for (int i = 0; i < (length + 1) / 2; ++i) {
numbers.addLast(Long.parseLong(elements[i]));
}
for (int i = (length + 1) / 2; i < length; ++i) {
String operator = elements[i];
boolean isValid = "+".equals(operator) || "-".equals(operator)
|| "*".equals(operator) || "/".equals(operator);
if (!isValid) {
throw new RuntimeException("Expression is invalid: " + expression);
}
long number1 = numbers.pollFirst();
long number2 = numbers.pollFirst();
long result = 0;
if (operator.equals("+")) {
result = number1 + number2;
} else if (operator.equals("-")) {
result = number1 - number2;
} else if (operator.equals("*")) {
result = number1 * number2;
} else if (operator.equals("/")) {
result = number1 / number2;
}
numbers.addFirst(result);
}
if (numbers.size() != 1) {
throw new RuntimeException("Expression is invalid: " + expression);
}
return numbers.pop();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
在上面的代码实现中, 语法规则的解析逻辑(第 23, 25, 27, 29 行)都集中在一个函数中, 对于简单的语法规则的解析, 这样的设计就足够了. 但对于复杂的语法规则的解析, 逻辑复杂, 代码量多, 所有的解析逻辑都耦合在一个函数中, 这样显然是不合适的. 这个时候, 就要考虑拆分代码, 将解析逻辑拆分到独立的小类中.
该怎么拆分呢? 可以借助解释器模式.
解释器模式的代码实现比较灵活, 没有固定的模板. 前面也说过, 应用设计模式主要是应对代码的复杂性, 实际上, 解释器模式也不例外. 它的代码实现的核心思想, 就是将语法解析的工作拆分到各个小类中, 以此来避免大而全的解析类. 一般的做法是, 将语法规则拆分成一些小的独立的单元, 然后对每个单元进行解析, 最终合并为对整个语法规则的解析.
前面定义的语法规则有两类表达式, 一类是数字, 一类是运算符, 运算符又包括加减乘除. 利用解释器模式, 把解析的工作拆分到 NumberExpression, AdditionExpression, SubstractionExpression, MultiplicationExpression, DivisionExpression 这样五个解析类中.
按照这个思路, 对代码进行重构, 重构之后的代码如下所示. 当然, 因为加减乘除表达式的解析比较简单, 利用解释器模式的设计思路, 看起来有点过度设计. 不过这里主要是为了解释原理, 明白意思就好, 不用过度细究这个例子.
public interface Expression {
long interpret();
}
public class NumberExpression implements Expression {
private long number;
public NumberExpression(long number) {
this.number = number;
}
public NumberExpression(String number) {
this.number = Long.parseLong(number);
}
@Override
public long interpret() {
return this.number;
}
}
public class AdditionExpression implements Expression {
private Expression exp1;
private Expression exp2;
public AdditionExpression(Expression exp1, Expression exp2) {
this.exp1 = exp1;
this.exp2 = exp2;
}
@Override
public long interpret() {
return exp1.interpret() + exp2.interpret();
}
}
// SubstractionExpression/MultiplicationExpression/DivisionExpression与AdditionExpression代码结构类似, 这里就省略了
public class ExpressionInterpreter {
private Deque<Expression> numbers = new LinkedList<>();
public long interpret(String expression) {
String[] elements = expression.split(" ");
int length = elements.length;
for (int i = 0; i < (length+1)/2; ++i) {
numbers.addLast(new NumberExpression(elements[i]));
}
for (int i = (length+1)/2; i < length; ++i) {
String operator = elements[i];
boolean isValid = "+".equals(operator) || "-".equals(operator)
|| "*".equals(operator) || "/".equals(operator);
if (!isValid) {
throw new RuntimeException("Expression is invalid: " + expression);
}
Expression exp1 = numbers.pollFirst();
Expression exp2 = numbers.pollFirst();
Expression combinedExp = null;
if (operator.equals("+")) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals("-")) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals("*")) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals("/")) {
combinedExp = new AdditionExpression(exp1, exp2);
}
long result = combinedExp.interpret();
numbers.addFirst(new NumberExpression(result));
}
if (numbers.size() != 1) {
throw new RuntimeException("Expression is invalid: " + expression);
}
return numbers.pop().interpret();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# 2.解释器模式实战举例
接下来, 再来看一个更加接近实战的例子: 如何实现一个自定义接口告警规则功能?
在平时的项目开发中, 监控系统非常重要, 它可以时刻监控业务系统的运行情况, 及时将异常报告给开发者. 比如, 如果每分钟接口出错数超过 100, 监控系统就通过短信, 微信, 邮件等方式发送告警给开发者.
一般来讲, 监控系统支持开发者自定义告警规则, 比如可以用下面这样一个表达式, 来表示一个告警规则, 它表达的意思是: 每分钟 API 总出错数超过 100 或者每分钟 API 总调用数超过 10000 就触发告警.
api_error_per_minute > 100 || api_count_per_minute > 10000
在监控系统中, 告警模块只负责根据统计数据和告警规则, 判断是否触发告警. 至于每分钟 API 接口出错数, 每分钟接口调用数等统计数据的计算, 是由其他模块来负责的. 其他模块将统计数据放到一个 Map 中(数据的格式如下所示), 发送给告警模块. 接下来只关注告警模块.
Map<String, Long> apiStat = new HashMap<>();
apiStat.put("api_error_per_minute", 103);
apiStat.put("api_count_per_minute", 987);
2
3
为了简化讲解和代码实现, 假设自定义的告警规则只包含 "||, &&, >, <, <mark>" 这五个运算符, 其中, ">, <, </mark>" 运算符的优先级高于 "||, &&" 运算符, "&&" 运算符优先级高于 "||". 在表达式中, 任意元素之间需要通过空格来分隔. 除此之外, 用户可以自定义要监控的 key, 比如前面的 api_error_per_minute, api_count_per_minute.
那如何实现上面的需求呢? 这里写了一个骨架代码, 如下所示, 其中的核心的实现没有给出.
public class AlertRuleInterpreter {
// key1 > 100 && key2 < 1000 || key3 == 200
public AlertRuleInterpreter(String ruleExpression) {
// TODO
}
//<String, Long> apiStat = new HashMap<>();
//apiStat.put("key1", 103);
//apiStat.put("key2", 987);
public boolean interpret(Map<String, Long> stats) {
// TODO
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DemoTest {
public static void main(String[] args) {
String rule = "key1 > 100 && key2 < 30 || key3 < 100 || key4 == 88";
AlertRuleInterpreter interpreter = new AlertRuleInterpreter(rule);
Map<String, Long> stats = new HashMap<>();
stats.put("key1", 101l);
stats.put("key3", 121l);
stats.put("key4", 88l);
boolean alert = interpreter.interpret(stats);
System.out.println(alert);
}
}
2
3
4
5
6
7
8
9
10
11
12
实际上, 可以把自定义的告警规则, 看作一种特殊 "语言" 的语法规则. 实现一个解释器, 能够根据规则, 针对用户输入的数据, 判断是否触发告警. 利用解释器模式, 把解析表达式的逻辑拆分到各个小类中, 避免大而复杂的大类的出现. 按照这个实现思路, 把刚刚的代码补全, 如下所示.
public interface Expression {
boolean interpret(Map<String, Long> stats);
}
public class GreaterExpression implements Expression {
private String key;
private long value;
public GreaterExpression(String strExpression) {
String[] elements = strExpression.trim().split("\\s+");
if (elements.length != 3 || !elements[1].trim().equals(">")) {
throw new RuntimeException("Expression is invalid: " + strExpression);
}
this.key = elements[0].trim();
this.value = Long.parseLong(elements[2].trim());
}
public GreaterExpression(String key, long value) {
this.key = key;
this.value = value;
}
@Override
public boolean interpret(Map<String, Long> stats) {
if (!stats.containsKey(key)) {
return false;
}
long statValue = stats.get(key);
return statValue > value;
}
}
// LessExpression/EqualExpression跟GreaterExpression代码类似, 这里就省略了
public class AndExpression implements Expression {
private List<Expression> expressions = new ArrayList<>();
public AndExpression(String strAndExpression) {
String[] strExpressions = strAndExpression.split("&&");
for (String strExpr : strExpressions) {
if (strExpr.contains(">")) {
expressions.add(new GreaterExpression(strExpr));
} else if (strExpr.contains("<")) {
expressions.add(new LessExpression(strExpr));
} else if (strExpr.contains("==")) {
expressions.add(new EqualExpression(strExpr));
} else {
throw new RuntimeException("Expression is invalid: " + strAndExpression);
}
}
}
public AndExpression(List<Expression> expressions) {
this.expressions.addAll(expressions);
}
@Override
public boolean interpret(Map<String, Long> stats) {
for (Expression expr : expressions) {
if (!expr.interpret(stats)) {
return false;
}
}
return true;
}
}
public class OrExpression implements Expression {
private List<Expression> expressions = new ArrayList<>();
public OrExpression(String strOrExpression) {
String[] andExpressions = strOrExpression.split("\\|\\|");
for (String andExpr : andExpressions) {
expressions.add(new AndExpression(andExpr));
}
}
public OrExpression(List<Expression> expressions) {
this.expressions.addAll(expressions);
}
@Override
public boolean interpret(Map<String, Long> stats) {
for (Expression expr : expressions) {
if (expr.interpret(stats)) {
return true;
}
}
return false;
}
}
public class AlertRuleInterpreter {
private Expression expression;
public AlertRuleInterpreter(String ruleExpression) {
this.expression = new OrExpression(ruleExpression);
}
public boolean interpret(Map<String, Long> stats) {
return expression.interpret(stats);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
重点回顾
解释器模式为某个语言定义它的语法(或者叫文法)表示, 并定义一个解释器用来处理这个语法. 从广义上来讲, 只要是能承载信息的载体, 都可以称之为 "语言".
要想了解 "语言" 要表达的信息, 就必须定义相应的语法规则. 这样书写者就可以根据语法规则来书写 "句子"(专业点的叫法应该是"表达式"), 阅读者根据语法规则来阅读 "句子", 这样才能做到信息的正确传递. 而要讲的解释器模式, 其实就是用来实现根据语法规则解读 "句子" 的解释器.
解释器模式的代码实现比较灵活, 没有固定的模板. 前面说过, 应用设计模式主要是应对代码的复杂性, 解释器模式也不例外. 它的代码实现的核心思想, 就是将语法解析的工作拆分到各个小类中, 以此来避免大而全的解析类. 一般的做法是, 将语法规则拆分一些小的独立的单元, 然后对每个单元进行解析, 最终合并为对整个语法规则的解析.
# 73-中介模式:什么时候用中介模式?什么时候用观察者模式?
今天来学习 23 种经典设计模式中的最后一个, 中介模式. 跟前面刚刚讲过的命令模式, 解释器模式类似, 中介模式也属于不怎么常用的模式, 应用场景比较特殊, 但跟它俩不同的是, 中介模式理解起来并不难, 代码实现也非常简单, 学习难度要小很多. 中介模式跟之前讲过的观察者模式有点相似, 所以今天还会详细讨论下这两种模式的区别.
# 1.中介模式的原理和实现
中介模式的英文翻译是 Mediator Design Pattern. 在 GoF 中的《设计模式》一书中, 它是这样定义的:
Mediator pattern defines a separate (mediator) object that encapsulates the interaction between a set of objects and the objects delegate their interaction to a mediator object instead of interacting with each other directly.
翻译成中文就是: 中介模式定义了一个单独的(中介)对象, 来封装一组对象之间的交互. 将这组对象之间的交互委派给与中介对象交互, 来避免对象之间的直接交互.
前面讲 "如何给代码解耦" 的时候, 其中一个方法就是引入中间层.
实际上, 中介模式的设计思想跟中间层很像, 通过引入中介这个中间层, 将一组对象之间的交互关系(或者说依赖关系)从多对多(网状关系)转换为一对多(星状关系) . 原来一个对象要跟 n 个对象交互, 现在只需要跟一个中介对象交互, 从而最小化对象之间的交互关系, 降低了代码的复杂度, 提高了代码的可读性和可维护性.
这里画了一张对象交互关系的对比图. 其中右边的交互图是利用中介模式对左边交互关系优化之后的结果, 从图中可以很直观地看出, 右边的交互关系更加清晰, 简洁.

提到中介模式, 有一个比较经典的例子不得不说, 那就是航空管制.
为了让飞机在飞行的时候互不干扰, 每架飞机都需要知道其他飞机每时每刻的位置, 这就需要时刻跟其他飞机通信. 飞机通信形成的通信网络就会无比复杂. 这个时候, 通过引入 "塔台" 这样一个中介, 让每架飞机只跟塔台来通信, 发送自己的位置给塔台, 由塔台来负责每架飞机的航线调度. 这样就大大简化了通信网络.
刚刚举的是生活中的例子, 再举一个跟编程开发相关的例子. 这个例子与 UI 控件有关, 算是中介模式比较经典的应用, 很多书籍在讲到中介模式的时候, 都会拿它来举例.
假设有一个比较复杂的对话框, 对话框中有很多控件, 比如按钮, 文本框, 下拉框等. 当对某个控件进行操作的时候, 其他控件会做出相应的反应, 比如在下拉框中选择 "注册", 注册相关的控件就会显示在对话框中. 如果在下拉框中选择 "登陆", 登陆相关的控件就会显示在对话框中.
按照通常习惯的 UI 界面的开发方式, 将刚刚的需求用代码实现出来, 就是下面这个样子. 在这种实现方式中, 控件和控件之间互相操作, 互相依赖.
public class UIControl {
private static final String LOGIN_BTN_ID = "login_btn";
private static final String REG_BTN_ID = "reg_btn";
private static final String USERNAME_INPUT_ID = "username_input";
private static final String PASSWORD_INPUT_ID = "pswd_input";
private static final String REPEATED_PASSWORD_INPUT_ID = "repeated_pswd_input";
private static final String HINT_TEXT_ID = "hint_text";
private static final String SELECTION_ID = "selection";
public static void main(String[] args) {
Button loginButton = (Button)findViewById(LOGIN_BTN_ID);
Button regButton = (Button)findViewById(REG_BTN_ID);
Input usernameInput = (Input)findViewById(USERNAME_INPUT_ID);
Input passwordInput = (Input)findViewById(PASSWORD_INPUT_ID);
Input repeatedPswdInput = (Input)findViewById(REPEATED_PASSWORD_INPUT_ID);
Text hintText = (Text)findViewById(HINT_TEXT_ID);
Selection selection = (Selection)findViewById(SELECTION_ID);
loginButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
String username = usernameInput.text();
String password = passwordInput.text();
// 校验数据...
// 做业务处理...
}
});
regButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// 获取usernameInput, passwordInput, repeatedPswdInput数据...
// 校验数据...
// 做业务处理...
}
});
// ...省略selection下拉选择框相关代码....
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
再按照中介模式, 将上面的代码重新实现一下. 在新的代码实现中, 各个控件只跟中介对象交互, 中介对象负责所有业务逻辑的处理.
public interface Mediator {
void handleEvent(Component component, String event);
}
public class LandingPageDialog implements Mediator {
private Button loginButton;
private Button regButton;
private Selection selection;
private Input usernameInput;
private Input passwordInput;
private Input repeatedPswdInput;
private Text hintText;
@Override
public void handleEvent(Component component, String event) {
if (component.equals(loginButton)) {
String username = usernameInput.text();
String password = passwordInput.text();
// 校验数据...
// 做业务处理...
} else if (component.equals(regButton)) {
// 获取usernameInput, passwordInput, repeatedPswdInput数据...
// 校验数据...
// 做业务处理...
} else if (component.equals(selection)) {
String selectedItem = selection.select();
if (selectedItem.equals("login")) {
usernameInput.show();
passwordInput.show();
repeatedPswdInput.hide();
hintText.hide();
// ...省略其他代码
} else if (selectedItem.equals("register")) {
// ....
}
}
}
}
public class UIControl {
private static final String LOGIN_BTN_ID = "login_btn";
private static final String REG_BTN_ID = "reg_btn";
private static final String USERNAME_INPUT_ID = "username_input";
private static final String PASSWORD_INPUT_ID = "pswd_input";
private static final String REPEATED_PASSWORD_INPUT_ID = "repeated_pswd_input";
private static final String HINT_TEXT_ID = "hint_text";
private static final String SELECTION_ID = "selection";
public static void main(String[] args) {
Button loginButton = (Button)findViewById(LOGIN_BTN_ID);
Button regButton = (Button)findViewById(REG_BTN_ID);
Input usernameInput = (Input)findViewById(USERNAME_INPUT_ID);
Input passwordInput = (Input)findViewById(PASSWORD_INPUT_ID);
Input repeatedPswdInput = (Input)findViewById(REPEATED_PASSWORD_INPUT_ID);
Text hintText = (Text)findViewById(HINT_TEXT_ID);
Selection selection = (Selection)findViewById(SELECTION_ID);
Mediator dialog = new LandingPageDialog();
dialog.setLoginButton(loginButton);
dialog.setRegButton(regButton);
dialog.setUsernameInput(usernameInput);
dialog.setPasswordInput(passwordInput);
dialog.setRepeatedPswdInput(repeatedPswdInput);
dialog.setHintText(hintText);
dialog.setSelection(selection);
loginButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
dialog.handleEvent(loginButton, "click");
}
});
regButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
dialog.handleEvent(regButton, "click");
}
});
//....
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
从代码中可以看出, 原本业务逻辑会分散在各个控件中, 现在都集中到了中介类中. 实际上, 这样做既有好处, 也有坏处. 好处是简化了控件之间的交互, 坏处是中介类有可能会变成大而复杂的 "上帝类"(God Class). 所以, 在使用中介模式的时候, 要根据实际的情况, 平衡对象之间交互的复杂度和中介类本身的复杂度.
# 2.中介模式VS观察者模式
前面讲观察者模式的时候讲到, 观察者模式有多种实现方式. 虽然经典的实现方式没法彻底解耦观察者和被观察者, 观察者需要注册到被观察者中, 被观察者状态更新需要调用观察者的 update() 方法. 但在跨进程的实现方式中, 可以利用消息队列实现彻底解耦, 观察者和被观察者都只需要跟消息队列交互, 观察者完全不知道被观察者的存在, 被观察者也完全不知道观察者的存在.
前面提到, 中介模式也是为了解耦对象之间的交互, 所有的参与者都只与中介进行交互. 而观察者模式中的消息队列, 就有点类似中介模式中的 "中介" , 观察者模式的中观察者和被观察者, 就有点类似中介模式中的 "参与者". 那问题来了: 中介模式和观察者模式的区别在哪里呢? 什么时候选择使用中介模式? 什么时候选择使用观察者模式呢?
在观察者模式中, 尽管一个参与者既可以是观察者, 同时也可以是被观察者, 但大部分情况下, 交互关系往往都是单向的, 一个参与者要么是观察者, 要么是被观察者, 不会兼具两种身份. 也就是说, 在观察者模式的应用场景中, 参与者之间的交互关系比较有条理.
而中介模式正好相反. 只有当参与者之间的交互关系错综复杂, 维护成本很高的时候, 才考虑使用中介模式. 毕竟, 中介模式的应用会带来一定的副作用, 前面也讲到, 它有可能会产生大而复杂的上帝类. 除此之外, 如果一个参与者状态的改变, 其他参与者执行的操作有一定先后顺序的要求, 这个时候, 中介模式就可以利用中介类, 通过先后调用不同参与者的方法, 来实现顺序的控制, 而观察者模式是无法实现这样的顺序要求的.
重点回顾
中介模式的设计思想跟中间层很像, 通过引入中介这个中间层, 将一组对象之间的交互关系(或者依赖关系)从多对多(网状关系)转换为一对多(星状关系). 原来一个对象要跟 n 个对象交互, 现在只需要跟一个中介对象交互, 从而最小化对象之间的交互关系, 降低了代码的复杂度, 提高了代码的可读性和可维护性.
观察者模式和中介模式都是为了实现参与者之间的解耦, 简化交互关系. 两者的不同在于应用场景上. 在观察者模式的应用场景中, 参与者之间的交互比较有条理, 一般都是单向的, 一个参与者只有一个身份, 要么是观察者, 要么是被观察者. 而在中介模式的应用场景中, 参与者之间的交互关系错综复杂, 既可以是消息的发送者, 也可以同时是消息的接收者.
# 74-总结回顾23种经典设计模式的原理,背后的思想,应用场景等
23 种经典设计模式共分为 3 种类型, 分别是创建型, 结构型和行为型. 今天把这 3 种类型分成 3 个对应的小模块, 逐一回顾一下每一种设计模式的原理, 实现, 设计意图和应用场景.
还是那句话, 如果你看了之后, 感觉都有印象, 那就说明学得还不错; 如果还能在脑子里形成自己的知识架构, 闭上眼睛都能回忆上来, 那说明你学得很好; 如果能有自己的理解, 并且在项目开发中, 开始思考代码质量问题, 开始用已经学过的设计模式来解决代码问题, 那说明你已经掌握这些内容的精髓.

# 1.创建型设计模式
创建型设计模式包括: 单例模式, 工厂模式, 建造者模式, 原型模式. 它主要解决对象的创建问题, 封装复杂的创建过程, 解耦对象的创建代码和使用代码.
# (1)单例模式
单例模式用来创建全局唯一的对象. 一个类只允许创建一个对象(或者叫实例), 那这个类就是一个单例类, 这种设计模式就叫作单例模式. 单例有几种经典的实现方式, 它们分别是: 饿汉式, 懒汉式, 双重检测, 静态内部类, 枚举.
尽管单例是一个很常用的设计模式, 在实际的开发中, 也确实经常用到它, 但是有些人认为单例是一种反模式(anti-pattern), 并不推荐使用, 主要的理由有以下几点:
- 单例对 OOP 特性的支持不友好
- 单例会隐藏类之间的依赖关系
- 单例对代码的扩展性不友好
- 单例对代码的可测试性不友好
- 单例不支持有参数的构造函数
那有什么替代单例的解决方案呢? 如果要完全解决这些问题, 可能要从根上寻找其他方式来实现全局唯一类. 比如, 通过工厂模式, IOC 容器来保证全局唯一性.
有人把单例当作反模式, 主张杜绝在项目中使用. 个人觉得这有点极端. 模式本身没有对错, 关键看你怎么用. 如果单例类并没有后续扩展的需求, 并且不依赖外部系统, 那设计成单例类就没有太大问题. 对于一些全局类, 在其他地方 new 的话, 还要在类之间传来传去, 不如直接做成单例类, 使用起来简洁方便.
除此之外, 还讲到了进程唯一单例, 线程唯一单例, 集群唯一单例, 多例等扩展知识点, 这一部分在实际的开发中并不会被用到, 但是可以扩展你的思路, 锻炼你的逻辑思维.
# (2)工厂模式
工厂模式包括简单工厂, 工厂方法, 抽象工厂这 3 种细分模式. 其中, 简单工厂和工厂方法比较常用, 抽象工厂的应用场景比较特殊, 所以很少用到.
工厂模式用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类), 由给定的参数来决定创建哪种类型的对象. 实际上, 如果创建对象的逻辑并不复杂, 那直接通过 new 来创建对象就可以了, 不需要使用工厂模式. 当创建逻辑比较复杂, 是一个 "大工程" 的时候, 就考虑使用工厂模式, 封装对象的创建过程, 将对象的创建和使用相分离.
当每个对象的创建逻辑都比较简单的时候, 推荐使用简单工厂模式, 将多个对象的创建逻辑放到一个工厂类中. 当每个对象的创建逻辑都比较复杂的时候, 为了避免设计一个过于庞大的工厂类, 推荐使用工厂方法模式, 将创建逻辑拆分得更细, 每个对象的创建逻辑独立到各自的工厂类中.
详细点说, 工厂模式的作用有下面 4 个, 这也是判断要不要使用工厂模式最本质的参考标准.
- 封装变化: 创建逻辑有可能变化, 封装成工厂类之后, 创建逻辑的变更对调用者透明.
- 代码复用: 创建代码抽离到独立的工厂类之后可以复用.
- 隔离复杂性: 封装复杂的创建逻辑, 调用者无需了解如何创建对象.
- 控制复杂度: 将创建代码抽离出来, 让原本的函数或类职责更单一, 代码更简洁.
除此之外, 还讲了工厂模式一个非常经典的应用场景: 依赖注入框架, 比如 Spring IOC, Google Guice, 它用来集中创建, 组装, 管理对象, 跟具体业务代码解耦, 让程序员聚焦在业务代码的开发上. DI 框架已经成为了平时开发的必备框架, 此外还带你实现了一个简单的 DI 框架.
# (3)建造者模式
建造者模式用来创建复杂对象, 可以通过设置不同的可选参数, "定制化" 地创建不同的对象. 建造者模式的原理和实现比较简单, 重点是掌握应用场景, 避免过度使用.
如果一个类中有很多属性, 为了避免构造函数的参数列表过长, 影响代码的可读性和易用性, 可以通过构造函数配合 set() 方法来解决. 但如果存在下面情况中的任意一种, 就要考虑使用建造者模式了.
- 把类的必填属性放到构造函数中, 强制创建对象的时候就设置. 如果必填的属性有很多, 把这些必填属性都放到构造函数中设置, 那构造函数就又会出现参数列表很长的问题. 如果把必填属性通过 set() 方法设置, 那校验这些必填属性是否已经填写的逻辑就无处安放了.
- 如果类的属性之间有一定的依赖关系或者约束条件, 继续使用构造函数配合 set() 方法的设计思路, 那这些依赖关系或约束条件的校验逻辑就无处安放了.
- 如果希望创建不可变对象, 也就是说, 对象在创建好之后, 就不能再修改内部的属性值, 要实现这个功能, 就不能在类中暴露 set() 方法. 构造函数配合 set() 方法来设置属性值的方式就不适用了.
# (4)原型模式
如果对象的创建成本比较大, 而同一个类的不同对象之间差别不大(大部分字段都相同), 在这种情况下, 可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式, 来创建新对象, 以达到节省创建时间的目的. 这种基于原型来创建对象的方式就叫作原型模式.
原型模式有两种实现方法, 深拷贝和浅拷贝. 浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址, 不会递归地复制引用对象, 以及引用对象的引用对象...而深拷贝得到的是一份完完全全独立的对象. 所以, 深拷贝比起浅拷贝来说, 更加耗时, 更加耗内存空间.
如果要拷贝的对象是不可变对象, 浅拷贝共享不可变对象是没问题的, 但对于可变对象来说, 浅拷贝得到的对象和原始对象会共享部分数据, 就有可能出现数据被修改的风险, 也就变得复杂多了. 除非操作非常耗时, 比较推荐使用浅拷贝, 否则, 没有充分的理由, 不要为了一点点的性能提升而使用浅拷贝.
# 2.结构型设计模式
结构型模式主要总结了一些类或对象组合在一起的经典结构, 这些经典的结构可以解决特定应用场景的问题. 结构型模式包括: 代理模式, 桥接模式, 装饰器模式, 适配器模式, 门面模式, 组合模式, 享元模式.
# (1)代理模式
代理模式在不改变原始类接口的条件下, 为原始类定义一个代理类, 主要目的是控制访问, 而非加强功能, 这是它跟装饰器模式最大的不同. 一般情况下, 让代理类和原始类实现同样的接口. 但如果原始类并没有定义接口, 并且原始类代码并不是自己开发维护的. 在这种情况下, 可以通过让代理类继承原始类的方法来实现代理模式.
静态代理需要针对每个类都创建一个代理类, 并且每个代理类中的代码都有点像模板式的 "重复" 代码, 增加了维护成本和开发成本. 对于静态代理存在的问题, 可以通过动态代理来解决. 不事先为每个原始类编写代理类, 而是在运行的时候动态地创建原始类对应的代理类, 然后在系统中用代理类替换掉原始类.
代理模式常用在业务系统中开发一些非功能性需求, 比如: 监控, 统计, 鉴权, 限流, 事务, 幂等, 日志. 将这些附加功能与业务功能解耦, 放到代理类统一处理, 让程序员只需要关注业务方面的开发. 除此之外, 代理模式还可以用在 RPC, 缓存等应用场景中.
# (2)桥接模式
桥接模式的代码实现非常简单, 但是理解起来稍微有点难度, 并且应用场景也比较局限, 所以, 相对来说, 桥接模式在实际的项目中并没有那么常用, 只需要简单了解, 见到能认识就可以了.
桥接模式有两种理解方式. 第一种理解方式是 "将抽象和实现解耦, 让它们能独立开发". 这种理解方式比较特别, 应用场景也不多. 另一种理解方式更加简单, 等同于 "组合优于继承" 设计原则, 这种理解方式更加通用, 应用场景比较多. 不管是哪种理解方式, 它们的代码结构都是相同的, 都是一种类之间的组合关系.
对于第一种理解方式, 弄懂定义中 "抽象" 和 "实现" 两个概念, 是理解它的关键. 定义中的 "抽象", 指的并非 "抽象类" 或 "接口", 而是被抽象出来的一套 "类库", 它只包含骨架代码, 真正的业务逻辑需要委派给定义中的 "实现" 来完成. 而定义中的 "实现", 也并非 "接口的实现类", 而是的一套独立的 "类库". "抽象" 和 "实现" 独立开发, 通过对象之间的组合关系组装在一起.
# (3)装饰器模式
装饰器模式主要解决继承关系过于复杂的问题, 通过组合来替代继承, 给原始类添加增强功能. 这也是判断是否该用装饰器模式的一个重要的依据. 除此之外, 装饰器模式还有一个特点, 那就是可以对原始类嵌套使用多个装饰器. 为了满足这样的需求, 在设计的时候, 装饰器类需要跟原始类继承相同的抽象类或者接口.
# (4)适配器模式
代理模式, 装饰器模式提供的都是跟原始类相同的接口, 而适配器提供跟原始类不同的接口. 适配器模式是用来做适配的, 它将不兼容的接口转换为可兼容的接口, 让原本由于接口不兼容而不能一起工作的类可以一起工作. 适配器模式有两种实现方式: 类适配器和对象适配器. 其中, 类适配器使用继承关系来实现, 对象适配器使用组合关系来实现.
适配器模式是一种事后的补救策略, 用来补救设计上的缺陷. 应用这种模式算是 "无奈之举". 如果在设计初期, 就能规避接口不兼容的问题, 那这种模式就无用武之地了. 在实际的开发中, 什么情况下才会出现接口不兼容呢? 我总结下了下面这 5 种场景:
- 封装有缺陷的接口设计
- 统一多个类的接口设计
- 替换依赖的外部系统
- 兼容老版本接口
- 适配不同格式的数据
# (5)门面模式
门面模式原理, 实现都非常简单, 应用场景比较明确. 它通过封装细粒度的接口, 提供组合各个细粒度接口的高层次接口, 来提高接口的易用性, 或者解决性能, 分布式事务等问题.
# (6)组合模式
组合模式跟之前讲的面向对象设计中的 "组合关系(通过组合来组装两个类)", 完全是两码事. 这里讲的 "组合模式", 主要是用来处理树形结构数据. 正因为其应用场景的特殊性, 数据必须能表示成树形结构, 这也导致了这种模式在实际的项目开发中并不那么常用. 但一旦数据满足树形结构, 应用这种模式就能发挥很大的作用, 能让代码变得非常简洁.
组合模式的设计思路, 与其说是一种设计模式, 倒不如说是对业务场景的一种数据结构和算法的抽象. 其中, 数据可以表示成树这种数据结构, 业务需求可以通过在树上的递归遍历算法来实现. 组合模式, 将一组对象组织成树形结构, 将单个对象和组合对象都看作树中的节点, 以统一处理逻辑, 并且它利用树形结构的特点, 递归地处理每个子树, 依次简化代码实现.
# (7)享元模式
所谓 "享元", 顾名思义就是被共享的单元. 享元模式的意图是复用对象, 节省内存, 前提是享元对象是不可变对象.
具体来讲, 当一个系统中存在大量重复对象的时候, 就可以利用享元模式, 将对象设计成享元, 在内存中只保留一份实例, 供多处代码引用, 这样可以减少内存中对象的数量, 以起到节省内存的目的. 实际上, 不仅仅相同对象可以设计成享元, 对于相似对象, 也可以将这些对象中相同的部分(字段), 提取出来设计成享元, 让这些大量相似对象引用这些享元.
# 3.行为型设计模式
创建型设计模式主要解决 "对象的创建" 问题, 结构型设计模式主要解决 "类或对象的组合" 问题, 那行为型设计模式主要解决的就是 "类或对象之间的交互" 问题. 行为型模式比较多, 有 11 种, 它们分别是: 观察者模式, 模板模式, 策略模式, 职责链模式, 迭代器模式, 状态模式, 访问者模式, 备忘录模式, 命令模式, 解释器模式, 中介模式.
# (1)观察者模式
观察者模式将观察者和被观察者代码解耦. 观察者模式的应用场景非常广泛, 小到代码层面的解耦, 大到架构层面的系统解耦, 再或者一些产品的设计思路, 都有这种模式的影子, 比如, 邮件订阅, RSS Feeds, 本质上都是观察者模式.
不同的应用场景和需求下, 这个模式也有截然不同的实现方式: 有同步阻塞的实现方式, 也有异步非阻塞的实现方式; 有进程内的实现方式, 也有跨进程的实现方式. 同步阻塞是最经典的实现方式, 主要是为了代码解耦; 异步非阻塞除了能实现代码解耦之外, 还能提高代码的执行效率; 进程间的观察者模式解耦更加彻底, 一般是基于消息队列来实现, 用来实现不同进程间的被观察者和观察者之间的交互.
框架的作用有隐藏实现细节, 降低开发难度, 实现代码复用, 解耦业务与非业务代码, 让程序员聚焦业务开发. 针对异步非阻塞观察者模式, 也可以将它抽象成 EventBus 框架来达到这样的效果. EventBus 翻译为 "事件总线", 它提供了实现观察者模式的骨架代码. 可以基于此框架非常容易地在自己的业务场景中实现观察者模式, 不需要从零开始开发.
# (2)模板模式
模板方法模式在一个方法中定义一个算法骨架, 并将某些步骤推迟到子类中实现. 模板方法模式可以让子类在不改变算法整体结构的情况下, 重新定义算法中的某些步骤. 这里的"算法", 可以理解为广义上的 "业务逻辑", 并不特指数据结构和算法中的 "算法". 这里的算法骨架就是 "模板", 包含算法骨架的方法就是 "模板方法", 这也是模板方法模式名字的由来.
模板模式有两大作用: 复用和扩展. 其中复用指的是, 所有的子类可以复用父类中提供的模板方法的代码. 扩展指的是, 框架通过模板模式提供功能扩展点, 让框架用户可以在不修改框架源码的情况下, 基于扩展点定制化框架的功能.
除此之外还讲到回调. 它跟模板模式具有相同的作用: 代码复用和扩展. 在一些框架, 类库, 组件等的设计中经常会用到, 比如 JdbcTemplate 就是用了回调. 相对于普通的函数调用, 回调是一种双向调用关系. A 类事先注册某个函数 F 到 B 类, A 类在调用 B 类的 P 函数的时候, B 类反过来调用 A 类注册给它的 F 函数. 这里的 F 函数就是"回调函数". A 调用 B, B 反过来又调用 A, 这种调用机制就叫作 "回调".
回调可以细分为同步回调和异步回调. 从应用场景上来看, 同步回调看起来更像模板模式, 异步回调看起来更像观察者模式. 回调跟模板模式的区别, 更多的是在代码实现上, 而非应用场景上. 回调基于组合关系来实现, 模板模式基于继承关系来实现. 回调比模板模式更加灵活.
# (3)策略模式
策略模式定义一族算法类, 将每个算法分别封装起来, 让它们可以互相替换. 策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码). 策略模式用来解耦策略的定义, 创建, 使用. 实际上, 一个完整的策略模式就是由这三个部分组成的.
策略类的定义比较简单, 包含一个策略接口和一组实现这个接口的策略类. 策略的创建由工厂类来完成, 封装策略创建的细节. 策略模式包含一组策略可选, 客户端代码选择使用哪个策略, 有两种确定方法: 编译时静态确定和运行时动态确定. 其中, "运行时动态确定" 才是策略模式最典型的应用场景.
在实际的项目开发中, 策略模式也比较常用. 最常见的应用场景是, 利用它来避免冗长的 if-else 或 switch 分支判断. 不过它的作用还不止如此. 它也可以像模板模式那样, 提供框架的扩展点等等. 实际上, 策略模式主要的作用还是解耦策略的定义, 创建和使用, 控制代码的复杂度, 让每个部分都不至于过于复杂, 代码量过多. 除此之外, 对于复杂代码来说, 策略模式还能让其满足开闭原则, 添加新策略的时候, 最小化, 集中化代码改动, 减少引入 bug 的风险.
# (4)职责链模式
在职责链模式中, 多个处理器依次处理同一个请求. 一个请求先经过 A 处理器处理, 然后再把请求传递给 B 处理器, B 处理器处理完后再传递给 C 处理器, 以此类推, 形成一个链条. 链条上的每个处理器各自承担各自的处理职责, 所以叫作职责链模式.
在 GoF 的定义中, 一旦某个处理器能处理这个请求, 就不会继续将请求传递给后续的处理器了. 当然, 在实际的开发中, 也存在对这个模式的变体, 那就是请求不会中途终止传递, 而是会被所有的处理器都处理一遍.
职责链模式常用在框架开发中, 用来实现过滤器, 拦截器功能, 让框架的使用者在不需要修改框架源码的情况下, 添加新的过滤, 拦截功能. 这也体现了之前讲到的对扩展开放, 对修改关闭的设计原则.
# (5)迭代器模式
迭代器模式也叫游标模式, 它用来遍历集合对象. 这里说的 "集合对象", 也可以叫 "容器", "聚合对象", 实际上就是包含一组对象的对象, 比如, 数组, 链表, 树, 图, 跳表. 迭代器模式主要作用是解耦容器代码和遍历代码. 大部分编程语言都提供了现成的迭代器可以使用, 不需要从零开始开发.
遍历集合一般有三种方式: for 循环, foreach 循环, 迭代器遍历. 后两种本质上属于一种, 都可以看作迭代器遍历. 相对于 for 循环遍历, 利用迭代器来遍历有 3 个优势:
- 迭代器模式封装集合内部的复杂数据结构, 开发者不需要了解如何遍历, 直接使用容器提供的迭代器即可;
- 迭代器模式将集合对象的遍历操作从集合类中拆分出来, 放到迭代器类中, 让两者的职责更加单一;
- 迭代器模式让添加新的遍历算法更加容易, 更符合开闭原则. 除此之外, 因为迭代器都实现自相同的接口, 在开发中, 基于接口而非实现编程, 替换迭代器也变得更加容易.
在通过迭代器来遍历集合元素的同时, 增加或者删除集合中的元素, 有可能会导致某个元素被重复遍历或遍历不到. 针对这个问题, 有两种比较干脆利索的解决方案, 来避免出现这种不可预期的运行结果. 一种是遍历的时候不允许增删元素, 另一种是增删元素之后让遍历报错. 第一种解决方案比较难实现, 因为很难确定迭代器使用结束的时间点. 第二种解决方案更加合理, Java 语言就是采用的这种解决方案. 增删元素之后, 选择 fail-fast 解决方式, 让遍历操作直接抛出运行时异常.
# (6)状态模式
状态模式一般用来实现状态机, 而状态机常用在游戏, 工作流引擎等系统开发中. 状态机又叫有限状态机, 它由 3 个部分组成: 状态, 事件, 动作. 其中, 事件也称为转移条件. 事件触发状态的转移及动作的执行. 不过, 动作不是必须的, 也可能只转移状态, 不执行任何动作.
针对状态机, 总结了三种实现方式.
第一种实现方式叫分支逻辑法. 利用 if-else 或者 switch-case 分支逻辑, 参照状态转移图, 将每一个状态转移原模原样地直译成代码. 对于简单的状态机来说, 这种实现方式最简单, 最直接, 是首选.
第二种实现方式叫查表法. 对于状态很多, 状态转移比较复杂的状态机来说, 查表法比较合适. 通过二维数组来表示状态转移图, 能极大地提高代码的可读性和可维护性.
第三种实现方式就是利用状态模式. 对于状态并不多, 状态转移也比较简单, 但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说, 我们首选这种实现方式.
# (7)访问者模式
访问者模式允许一个或者多个操作应用到一组对象上, 设计意图是解耦操作和对象本身, 保持类职责单一, 满足开闭原则以及应对代码的复杂性.
对于访问者模式, 学习的主要难点在代码实现. 而代码实现比较复杂的主要原因是, 函数重载在大部分面向对象编程语言中是静态绑定的. 也就是说, 调用类的哪个重载函数, 是在编译期间, 由参数的声明类型决定的, 而非运行时, 根据参数的实际类型决定的. 除此之外, 还讲到 Double Disptach. 如果某种语言支持 Double Dispatch, 那就不需要访问者模式了.
正是因为代码实现难理解, 所以在项目中应用这种模式, 会导致代码的可读性比较差. 所以, 除非不得已, 不要使用这种模式.
# (8)备忘录模式
备忘录模式也叫快照模式, 具体来说, 就是在不违背封装原则的前提下, 捕获一个对象的内部状态, 并在该对象之外保存这个状态, 以便之后恢复对象为先前的状态. 这个模式的定义表达了两部分内容: 一部分是, 存储副本以便后期恢复; 另一部分是, 要在不违背封装原则的前提下, 进行对象的备份和恢复.
备忘录模式的应用场景也比较明确和有限, 主要用来防丢失, 撤销, 恢复等. 它跟平时说的 "备份" 很相似. 两者的主要区别在于, 备忘录模式更侧重于代码的设计和实现, 备份更侧重架构设计或产品设计.
对于大对象的备份来说, 备份占用的存储空间会比较大, 备份和恢复的耗时会比较长. 针对这个问题, 不同的业务场景有不同的处理方式. 比如, 只备份必要的恢复信息, 结合最新的数据来恢复; 再比如, 全量备份和增量备份相结合, 低频全量备份, 高频增量备份, 两者结合来做恢复.
# (9)命令模式
命令模式在平时工作中并不常用, 稍微了解一下就可以.
落实到编码实现, 命令模式用到最核心的实现手段, 就是将函数封装成对象. 在大部分编程语言中, 函数是没法作为参数传递给其他函数的, 也没法赋值给变量. 借助命令模式, 将函数封装成对象, 这样就可以实现把函数像对象一样使用.
命令模式的主要作用和应用场景, 是用来控制命令的执行, 比如, 异步, 延迟, 排队执行命令, 撤销重做命令, 存储命令, 给命令记录日志等, 这才是命令模式能发挥独一无二作用的地方.
# (10)解释器模式
解释器模式为某个语言定义它的语法(或者叫文法)表示, 并定义一个解释器用来处理这个语法. 从广义上来讲, 只要是能承载信息的载体, 都可以称之为 "语言".
要想了解 "语言" 要表达的信息, 就必须定义相应的语法规则. 这样书写者就可以根据语法规则来书写 "句子"(专业点的叫法应该是"表达式"), 阅读者根据语法规则来阅读 "句子", 这样才能做到信息的正确传递. 而要讲的解释器模式, 其实就是用来实现根据语法规则解读 "句子" 的解释器.
解释器模式的代码实现比较灵活, 没有固定的模板. 前面说过, 应用设计模式主要是应对代码的复杂性, 解释器模式也不例外. 它的代码实现的核心思想, 就是将语法解析的工作拆分到各个小类中, 以此来避免大而全的解析类. 一般的做法是, 将语法规则拆分一些小的独立的单元, 然后对每个单元进行解析, 最终合并为对整个语法规则的解析.
# (11)中介模式
中介模式的设计思想跟中间层很像, 通过引入中介这个中间层, 将一组对象之间的交互关系(或者说依赖关系)从多对多(网状关系)转换为一对多(星状关系). 原来一个对象要跟 n 个对象交互, 现在只需要跟一个中介对象交互, 从而最小化对象之间的交互关系, 降低了代码的复杂度, 提高了代码的可读性和可维护性.
观察者模式和中介模式都是为了实现参与者之间的解耦, 简化交互关系. 两者的不同在于应用场景上. 在观察者模式的应用场景中, 参与者之间的交互比较有条理, 一般都是单向的, 一个参与者只有一个身份, 要么是观察者, 要么是被观察者. 而在中介模式的应用场景中, 参与者之间的交互关系错综复杂, 既可以是消息的发送者, 也可以同时是消息的接收者.
# 开源实战
# 75-在实际的项目开发中,如何避免过度设计?又如何避免设计不足?
在我过往的项目经历中, 经常遇到两种同事.
一种同事会过度设计. 在开始编写代码之前, 他会花很长时间做代码设计, 在开发过程中应用各种设计模式, 美其名曰未雨绸缪, 希望代码更加灵活, 为未来的扩展打好基础, 实则过度设计, 未来的需求并不一定会实现, 实际上是增加了代码的复杂度, 以后的所有开发都要在这套复杂的设计基础之上来完成.
除此之外, 还有一种是设计不足. 怎么简单怎么来, 写出来的代码能跑就可以, 顶多算是 demo, 看似在实践 KISS, YAGNI 原则, 实则忽略了设计环节, 代码毫无扩展性, 灵活性可言, 添加, 修改一个很小的功能就要改动很多代码.
所以, 今天讲讲在实际的项目开发中, 如何避免过度设计, 以及如何避免设计不足.
# 1.设计的初衷是提高代码质量
创业时经常会讲到一个词: 初心. 这词说的其实就是, 你到底是为什么干这件事. 不管走多远, 产品经过多少迭代, 转变多少次方向, "初心" 一般都不会随便改. 当在为产品该不该转型, 该不该做某个功能而拿捏不定的时候, 想想它符不符合我们创业的初心, 有时候就自然有答案了.
实际上, 应用设计模式也是如此. 应用设计模式只是方法, 最终的目的, 也就是初心, 是提高代码的质量. 具体点说就是, 提高代码的可读性, 可扩展性, 可维护性等. 所有的设计都是围绕着这个初心来做的.
所以, 在做代码设计的时候, 一定要先问下自己, 为什么要这样设计, 为什么要应用这种设计模式, 这样做是否能真正地提高代码质量, 能提高代码质量的哪些方面. 如果自己很难讲清楚, 或者给出的理由都比较牵强, 没有压倒性的优势, 那基本上就可以断定这是一种过度设计, 是为了设计而设计.
实际上, 设计原则和思想是心法, 设计模式只是招式. 掌握心法, 以不变应万变, 无招胜有招. 所以, 设计原则和思想比设计模式更加普适, 重要. 掌握了设计原则和思想, 能更清楚地了解为什么要用某种设计模式, 就能更恰到好处地应用设计模式, 甚至还可以自己创造出来新的设计模式.
# 2.设计的过程是先有问题后有方案
如果把写出的代码看作产品, 那做产品的时候, 先要思考痛点在哪里, 用户的真正需求在哪里, 然后再看要开发哪些功能去满足, 而不是先拍脑袋想出一个花哨的功能, 再去东搬西凑硬编出一个需求来.
代码设计也是类似的. 先要去分析代码存在的痛点, 比如可读性不好, 可扩展性不好等等, 然后再针对性地利用设计模式去改善, 而不是看到某个场景之后, 觉得跟之前在某本书中看到的某个设计模式的应用场景很相似, 就套用上去, 也不考虑到底合不合适, 最后如果有人问起了, 就再找几个不痛不痒, 很不具体的伪需求来搪塞, 比如提高了代码的扩展性, 满足了开闭原则等等.
实际上, 很多没有太多开发经验的新手, 往往在学完设计模式之后会非常 "学生气", 拿原理当真理, 不懂得具体问题具体分析, 手里拿着锤子看哪都是钉子, 不分青红皂白, 上来就是套用某个设计模式. 写完之后, 看着自己写的很复杂的代码, 还沾沾自喜, 甚至到处炫耀. 这完全是无知地炫技, 半瓶子不满大抵就是这个样子的. 等你慢慢成长之后, 回过头来再看自己当年的代码, 我相信你应该会感到脸红的. 这里我的话说得有点重, 我主要还是担心你以后在项目中, 过度设计被别人鄙视, 所以提前给你打个预防针!
所以本专栏讲解中, 一直是从问题讲起, 一步一步展示为什么要用某个设计模式, 而不是一开始就告诉你最终的设计. 实际上, 这还不是最重要的, 最重要的是需要分析问题, 解决问题的能力. 这样, 看到某段代码之后, 就能够自己分析得头头是道, 说出它好的地方, 不好的地方, 为什么好, 为什么不好, 不好的如何改善, 可以应用哪种设计模式, 应用了之后有哪些副作用要控制等等.
相反, 如果只是掌握了理论知识, 即便把 23 种设计模式的原理和实现背得滚瓜烂熟, 不具备具体问题具体分析的能力, 在面对真实项目的千变万化的代码的时候, 很容易就会滥用设计模式, 过度设计.
# 3.设计的应用场景是复杂代码
很多设计模式相关的书籍都会举一些简单的例子, 这些例子仅仅具有教学意义, 只是为了讲解设计模式的原理和实现, 力求在有限篇幅内给你讲明白. 而很多人就会误以为这些简单的例子就是这些设计模式的典型应用场景, 常常照葫芦画瓢地应用到自己的项目中, 用复杂的设计模式去解决简单的问题, 还振振有词地说某某经典书中就是这么写的. 这是很多初学者因为缺乏经验, 在学完设计模式之后, 在项目中过度设计的首要原因.
设计模式要干的事情就是解耦, 也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类, 让其满足高内聚低耦合等特性. 创建型模式是将创建和使用代码解耦, 结构型模式是将不同的功能代码解耦, 行为型模式是将不同的行为代码解耦. 而解耦的主要目的是应对代码的复杂性. 设计模式就是为了解决复杂代码问题而产生的.
因此, 对于复杂代码, 比如项目代码量多, 开发周期长, 参与开发的人员多, 前期要多花点时间在设计上, 越是复杂代码, 花在设计上的时间就要越多.
不仅如此, 每次提交的代码, 都要保证代码质量, 都要经过足够的思考和精心的设计, 这样才能避免烂代码效应(每次提交的代码质量都不是太好, 最终积累起来整个项目的质量就变得很差).
相反, 如果只是一个简单的项目, 代码量不多, 开发人员也不多, 那简单的问题用简单的解决方案就好, 不要引入过于复杂的设计模式, 将简单问题复杂化.
# 4.持续重构能有效避免过度设计
应用设计模式会提高代码的可扩展性, 但同时也会带来代码可读性的降低, 复杂度的升高. 一旦引入某个复杂的设计, 之后即便在很长一段时间都没有扩展的需求, 也不可能将这个复杂的设计删除, 整个团队都要一直背负着这个复杂的设计前行.
为了避免错误的需求预判导致的过度设计, 我非常推崇持续重构的开发方法. 持续重构不仅仅是保证代码质量的重要手段, 也是避免过度设计的有效方法. 在真正有痛点的时候, 再去考虑用设计模式来解决, 而不是一开始就为不一定实现的未来需求而应用设计模式.
当对要不要应用某种设计模式感到模棱两可的时候, 可以思考一下, 如果暂时不用这种设计模式, 随着代码的演进, 当某一天不得不去使用它的时候, 重构的代码是否很大. 如果不是, 那能不用就不用, 怎么简单就怎么来. 说句实话, 对于 10 万行以内的代码, 团队成员稳定, 对代码涉及的业务比较熟悉的情况下, 即便将所有的代码都推倒重写, 也不会花太多时间, 因此也不必为代码的扩展性太过担忧.
# 5.避免设计不足的3个必要条件
前面大部分讲到的都是如何避免过度设计, 再稍微讲讲如何避免设计不足.
首先, 要有一定理论知识的储备.
比如要熟练掌握各种设计原则, 思想, 编码规范, 设计模式. 理论知识是解决问题的工具, 是前人智慧的结晶. 没有理论知识, 就相当于游戏中没有厉害的装备, 虽然可以靠身手徒手打怪, 但肯定会影响发挥.
其次, 还要有一定的刻意训练.
很多同学很苦恼, 说理论知识都学过, 但是很容易忘记, 遇到问题也想不到对应的知识点. 实际上, 这就是缺乏理论结合实践的刻意训练. 回想一下上学的时候, 老师讲解完某个知识点之后, 往往会配合讲解几道例题, 然后再让你做上个几十道题去强化这个知识点. 这样再遇到类似的问题的时候, 就能不由自主地联想到相应的知识点. 而工作之后自己看书学知识, 别说拿几个场景来实践了, 大部分都是走马观花地看看, 没有经过刻意的训练, 知识积累不了, 能力也锻炼不了, 等于白学.
最后, 一定要有代码质量意识, 设计意识.
在写代码之前, 要多想想未来会有哪些扩展的需求, 哪部分是会变的, 哪部分是不变的, 这样写会不会导致之后添加新的功能比较困难, 代码的可读性好不好等代码质量问题. 有了这样的意识, 就离写出高质量的代码不远了.
# 6.不要脱离具体的场景去谈设计
设计是一个非常主观的事情, 不夸张地讲, 可以称之为一门 "艺术". 那相应地, 好坏就很难评判了. 如果真的要评判, 要放到具体的场景中. 脱离具体的场景去谈论设计是否合理, 都是空谈. 脱离业务谈架构都是 "耍流氓".
比如, 一个手游项目是否能被市场接受, 往往非常不确定. 很多手游项目开发出来之后, 市场反馈很差, 立马就放弃了. 除此之外, 尽快上市占领市场也是一款手游致胜的关键. 所以对于手游项目的开发来说, 往往前期不会花太多的时间在代码设计, 代码质量上.
相反, 如果你开发的是 MMORPG 大型端游, 一般都要投资上亿资金, 几百号人开发好几年, 推倒重来的成本很大. 这个时候, 代码质量就非常关键了. 前期就要多花点时间在设计上, 否则, 代码质量太差, bug 太多, 后期无法维护, 也会导致很多用户弃而选择同类型的其他家的游戏.
再比如, 如果开发的是偏底层的, 框架类的, 通用的代码, 那代码质量就比较重要, 因为一旦出现问题或者代码改动, 影响面就比较大. 相反, 如果开发的是业务系统或者不需要长期维护的项目, 那稍微放低点代码质量的要求, 也是没问题的, 而且自己的代码跟其他项目没有太多耦合, 即便出了问题, 影响也不大.
# 76-开源实战一(上):通过剖析Java JDK源码学习灵活应用设计模式
从今天开始, 就正式地进入到实战环节. 实战环节包括两部分, 一部分是开源项目实战, 另一部分是项目实战.
在开源项目实战部分, 会剖析几个经典的开源项目中用到的设计原则, 思想和模式, 这其中就包括对 Java JDK, Unix, Google Guava, Spring, MyBatis 这样五个开源项目的分析. 在项目实战部分, 精心挑选了几个实战项目, 手把手地带你利用之前学过的设计原则, 思想, 模式, 来对它们进行分析, 设计和代码实现, 这其中就包括鉴权限流, 幂等重试, 灰度发布这样三个项目.
接下来的两节重点剖析 Java JDK 中用到的几种常见的设计模式. 学习的目的是让你体会, 在真实的项目开发中, 要学会活学活用, 切不可过于死板, 生搬硬套设计模式的设计与实现. 除此之外, 针对每个模式, 不可能像前面学习理论知识那样, 分析得细致入微, 很多都是点到为止.
# 1.工厂模式在Calendar类中的应用
在前面讲到工厂模式的时候, 大部分工厂类都是以 Factory 作为后缀来命名, 并且工厂类主要负责创建对象这样一件事情. 但在实际的项目开发中, 工厂类的设计更加灵活. 那就来看下, 工厂模式在 Java JDK 中的一个应用: java.util.Calendar. 从命名上, 其实无法看出它是一个工厂类.
Calendar 类提供了大量跟日期相关的功能代码, 同时又提供了一个 getInstance() 工厂方法, 用来根据不同的 TimeZone 和 Locale 创建不同的 Calendar 子类对象. 也就是说, 功能代码和工厂方法代码耦合在了一个类中. 所以, 即便去查看它的源码, 如果不细心的话, 也很难发现它用到了工厂模式. 同时, 因为它不单单是一个工厂类, 所以它并没有以 Factory 作为后缀来命名.
Calendar 类的相关代码如下所示, 大部分代码都已经省略, 我只给出了 getInstance() 工厂方法的代码实现. 从代码可以看出, getInstance() 方法可以根据不同 TimeZone 和 Locale, 创建不同的 Calendar 子类对象, 比如 BuddhistCalendar, JapaneseImperialCalendar, GregorianCalendar, 这些细节完全封装在工厂方法中, 使用者只需要传递当前的时区和地址, 就能够获得一个 Calendar 类对象来使用, 而获得的对象具体是哪个 Calendar 子类的对象, 使用者在使用的时候并不关心.
public abstract class Calendar implements Serializable, Cloneable, Comparable<Calendar> {
//...
public static Calendar getInstance(TimeZone zone, Locale aLocale){
return createCalendar(zone, aLocale);
}
private static Calendar createCalendar(TimeZone zone,Locale aLocale) {
CalendarProvider provider = LocaleProviderAdapter.getAdapter(
CalendarProvider.class, aLocale).getCalendarProvider();
if (provider != null) {
try {
return provider.getInstance(zone, aLocale);
} catch (IllegalArgumentException iae) {
// fall back to the default instantiation
}
}
Calendar cal = null;
if (aLocale.hasExtensions()) {
String caltype = aLocale.getUnicodeLocaleType("ca");
if (caltype != null) {
switch (caltype) {
case "buddhist":
cal = new BuddhistCalendar(zone, aLocale);
break;
case "japanese":
cal = new JapaneseImperialCalendar(zone, aLocale);
break;
case "gregory":
cal = new GregorianCalendar(zone, aLocale);
break;
}
}
}
if (cal <mark> null) {
if (aLocale.getLanguage() </mark> "th" && aLocale.getCountry() == "TH") {
cal = new BuddhistCalendar(zone, aLocale);
} else if (aLocale.getVariant() <mark> "JP" && aLocale.getLanguage() </mark> "ja" && aLocale.getCountry() == "JP") {
cal = new JapaneseImperialCalendar(zone, aLocale);
} else {
cal = new GregorianCalendar(zone, aLocale);
}
}
return cal;
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 2.建造者模式在Calendar类中的应用
还是刚刚的 Calendar 类, 它不仅仅用到了工厂模式, 还用到了建造者模式. 建造者模式有两种实现方法, 一种是单独定义一个 Builder 类, 另一种是将 Builder 实现为原始类的内部类. Calendar 就采用了第二种实现思路. 先来看代码再讲解.
public abstract class Calendar implements Serializable, Cloneable, Comparable<Calendar> {
//...
public static class Builder {
private static final int NFIELDS = FIELD_COUNT + 1;
private static final int WEEK_YEAR = FIELD_COUNT;
private long instant;
private int[] fields;
private int nextStamp;
private int maxFieldIndex;
private String type;
private TimeZone zone;
private boolean lenient = true;
private Locale locale;
private int firstDayOfWeek, minimalDaysInFirstWeek;
public Builder() {}
public Builder setInstant(long instant) {
if (fields != null) {
throw new IllegalStateException();
}
this.instant = instant;
nextStamp = COMPUTED;
return this;
}
//...省略n多set()方法
public Calendar build() {
if (locale == null) {
locale = Locale.getDefault();
}
if (zone == null) {
zone = TimeZone.getDefault();
}
Calendar cal;
if (type == null) {
type = locale.getUnicodeLocaleType("ca");
}
if (type <mark> null) {
if (locale.getCountry() </mark> "TH" && locale.getLanguage() == "th") {
type = "buddhist";
} else {
type = "gregory";
}
}
switch (type) {
case "gregory":
cal = new GregorianCalendar(zone, locale, true);
break;
case "iso8601":
GregorianCalendar gcal = new GregorianCalendar(zone, locale, true);
// make gcal a proleptic Gregorian
gcal.setGregorianChange(new Date(Long.MIN_VALUE));
// and week definition to be compatible with ISO 8601
setWeekDefinition(MONDAY, 4);
cal = gcal;
break;
case "buddhist":
cal = new BuddhistCalendar(zone, locale);
cal.clear();
break;
case "japanese":
cal = new JapaneseImperialCalendar(zone, locale, true);
break;
default:
throw new IllegalArgumentException("unknown calendar type: " + type);
}
cal.setLenient(lenient);
if (firstDayOfWeek != 0) {
cal.setFirstDayOfWeek(firstDayOfWeek);
cal.setMinimalDaysInFirstWeek(minimalDaysInFirstWeek);
}
if (isInstantSet()) {
cal.setTimeInMillis(instant);
cal.complete();
return cal;
}
if (fields != null) {
boolean weekDate = isSet(WEEK_YEAR) && fields[WEEK_YEAR] > fields[YEAR];
if (weekDate && !cal.isWeekDateSupported()) {
throw new IllegalArgumentException("week date is unsupported by " + type);
}
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
for (int index = 0; index <= maxFieldIndex; index++) {
if (fields[index] == stamp) {
cal.set(index, fields[NFIELDS + index]);
break;
}
}
}
if (weekDate) {
int weekOfYear = isSet(WEEK_OF_YEAR) ? fields[NFIELDS + WEEK_OF_YEAR] : 1;
int dayOfWeek = isSet(DAY_OF_WEEK) ? fields[NFIELDS + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
cal.setWeekDate(fields[NFIELDS + WEEK_YEAR], weekOfYear, dayOfWeek);
}
cal.complete();
}
return cal;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
看了上面的代码, 有一个问题请思考一下: 既然已经有了 getInstance() 工厂方法来创建 Calendar 类对象, 为什么还要用 Builder 来创建 Calendar 类对象呢? 这两者之间的区别在哪里呢?
工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类), 由给定的参数来决定创建哪种类型的对象. 建造者模式用来创建一种类型的复杂对象, 通过设置不同的可选参数, "定制化" 地创建不同的对象.
网上有一个经典的例子很好地解释了两者的区别.
顾客走进一家餐馆点餐, 利用工厂模式, 根据用户不同的选择, 来制作不同的食物, 比如披萨, 汉堡, 沙拉. 对于披萨来说, 用户又有各种配料可以定制, 比如奶酪, 西红柿, 起司, 通过建造者模式根据用户选择的不同配料来制作不同的披萨.
粗看 Calendar 的 Builder 类的 build() 方法, 可能会觉得它有点像工厂模式. 没错, 前面一半代码确实跟 getInstance() 工厂方法类似, 根据不同的 type 创建了不同的 Calendar 子类. 实际上, 后面一半代码才属于标准的建造者模式, 根据 setXXX() 方法设置的参数, 来定制化刚刚创建的 Calendar 子类对象.
你可能会说, 这还能算是建造者模式吗? 其实不是:
我们也不要太学院派, 非得把工厂模式, 建造者模式分得那么清楚, 需要知道的是, 每个模式为什么这么设计, 能解决什么问题. 只有了解了这些最本质的东西, 才能不生搬硬套, 才能灵活应用, 甚至可以混用各种模式, 创造出新的模式来解决特定场景的问题.
实际上, 从 Calendar 这个例子也能学到, 不要过于死板地套用各种模式的原理和实现, 不要不敢做丝毫的改动. 模式是死的, 用的人是活的. 在实际上的项目开发中, 不仅各种模式可以混合在一起使用, 而且具体的代码实现, 也可以根据具体的功能需求做灵活的调整.
# 3.装饰器模式在Collections类中的应用
前面讲到, Java IO 类库是装饰器模式的非常经典的应用. 实际上, Java 的 Collections 类也用到了装饰器模式.
Collections 类是一个集合容器的工具类, 提供了很多静态方法, 用来创建各种集合容器, 比如通过 unmodifiableColletion() 静态方法, 来创建 UnmodifiableCollection 类对象. 而这些容器类中的 UnmodifiableCollection 类, CheckedCollection 和 SynchronizedCollection 类, 就是针对 Collection 类的装饰器类.
因为刚刚提到的这三个装饰器类, 在代码结构上几乎一样, 所以这里只拿其中的 UnmodifiableCollection 类来举例讲解一下. UnmodifiableCollection 类是 Collections 类的一个内部类, 相关代码我摘抄到了下面.
public class Collections {
private Collections() {}
public static <T> Collection<T> unmodifiableCollection(Collection<? extends T> c) {
return new UnmodifiableCollection<>(c);
}
static class UnmodifiableCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 1820017752578914078L;
final Collection<? extends E> c;
UnmodifiableCollection(Collection<? extends E> c) {
if (c==null)
throw new NullPointerException();
this.c = c;
}
public int size() {return c.size();}
public boolean isEmpty() {return c.isEmpty();}
public boolean contains(Object o) {return c.contains(o);}
public Object[] toArray() {return c.toArray();}
public <T> T[] toArray(T[] a) {return c.toArray(a);}
public String toString() {return c.toString();}
public Iterator<E> iterator() {
return new Iterator<E>() {
private final Iterator<? extends E> i = c.iterator();
public boolean hasNext() {return i.hasNext();}
public E next() {return i.next();}
public void remove() {
throw new UnsupportedOperationException();
}
@Override
public void forEachRemaining(Consumer<? super E> action) {
// Use backing collection version
i.forEachRemaining(action);
}
};
}
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
public boolean containsAll(Collection<?> coll) {
return c.containsAll(coll);
}
public boolean addAll(Collection<? extends E> coll) {
throw new UnsupportedOperationException();
}
public boolean removeAll(Collection<?> coll) {
throw new UnsupportedOperationException();
}
public boolean retainAll(Collection<?> coll) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// Override default methods in Collection
@Override
public void forEach(Consumer<? super E> action) {
c.forEach(action);
}
@Override
public boolean removeIf(Predicate<? super E> filter) {
throw new UnsupportedOperationException();
}
@SuppressWarnings("unchecked")
@Override
public Spliterator<E> spliterator() {
return (Spliterator<E>)c.spliterator();
}
@SuppressWarnings("unchecked")
@Override
public Stream<E> stream() {
return (Stream<E>)c.stream();
}
@SuppressWarnings("unchecked")
@Override
public Stream<E> parallelStream() {
return (Stream<E>)c.parallelStream();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
看了上面的代码, 请思考一下, 为什么说 UnmodifiableCollection 类是 Collection 类的装饰器类呢? 这两者之间可以看作简单的接口实现关系或者类继承关系吗?
前面讲过, 装饰器模式中的装饰器类是对原始类功能的增强. 尽管 UnmodifiableCollection 类可以算是对 Collection 类的一种功能增强, 但这点还不具备足够的说服力来断定 UnmodifiableCollection 就是 Collection 类的装饰器类.
实际上, 最关键的一点是, UnmodifiableCollection 的构造函数接收一个 Collection 类对象, 然后对其所有的函数进行了包裹(Wrap): 重新实现(比如 add() 函数)或者简单封装(比如 stream() 函数). 而简单的接口实现或者继承, 并不会如此来实现 UnmodifiableCollection 类. 所以从代码实现的角度来说, UnmodifiableCollection 类是典型的装饰器类.
# 4.适配器模式在Collections类中的应用
适配器模式可以用来兼容老的版本接口. 前面还举了一个 JDK 的例子, 这里再重新仔细看一下.
老版本的 JDK 提供了 Enumeration 类来遍历容器. 新版本的 JDK 用 Iterator 类替代 Enumeration 类来遍历容器. 为了兼容老的客户端代码(使用老版本 JDK 的代码), 保留了 Enumeration 类, 并且在 Collections 类中, 仍然保留了 enumaration() 静态方法(因为一般都是通过这个静态函数来创建一个容器的 Enumeration 类对象).
不过, 保留 Enumeration 类和 enumeration() 函数, 都只是为了兼容, 实际上, 跟适配器没有一点关系. 那到底哪一部分才是适配器呢?
在新版本的 JDK 中, Enumeration 类是适配器类. 它适配的是客户端代码(使用 Enumeration 类)和新版本 JDK 中新的迭代器 Iterator 类. 不过, 从代码实现的角度来说, 这个适配器模式的代码实现, 跟经典的适配器模式的代码实现, 差别稍微有点大. enumeration() 静态函数的逻辑和 Enumeration 适配器类的代码耦合在一起, enumeration() 静态函数直接通过 new 的方式创建了匿名类对象. 具体的代码如下所示:
/**
* Returns an enumeration over the specified collection. This provides
* interoperability with legacy APIs that require an enumeration
* as input.
*
* @param <T> the class of the objects in the collection
* @param c the collection for which an enumeration is to be returned.
* @return an enumeration over the specified collection.
* @see Enumeration
*/
public static <T> Enumeration<T> enumeration(final Collection<T> c) {
return new Enumeration<T>() {
private final Iterator<T> i = c.iterator();
public boolean hasMoreElements() {
return i.hasNext();
}
public T nextElement() {
return i.next();
}
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
重点回顾
今天重点讲了工厂模式, 建造者模式, 装饰器模式, 适配器模式, 这四种模式在 Java JDK 中的应用, 主要目的是给你展示真实项目中是如何灵活应用设计模式的.
尽管在之前的理论讲解中, 都有讲到每个模式的经典代码实现, 但在真实的项目开发中, 这些模式的应用更加灵活, 代码实现更加自由, 可以根据具体的业务场景, 功能需求, 对代码实现做很大的调整, 甚至还可能会对模式本身的设计思路做调整.
比如, Java JDK 中的 Calendar 类, 就耦合了业务功能代码, 工厂方法, 建造者类三种类型的代码, 而且, 在建造者类的 build 方法中, 前半部分是工厂方法的代码实现, 后半部分才是真正的建造者模式的代码实现. 所以, 在项目中应用设计模式, 切不可生搬硬套, 过于学院派, 要学会结合实际情况做灵活调整, 做到心中无剑胜有剑.
# 77-开源实战一(下):通过剖析Java JDK源码学习灵活应用设计模式
今天再重点讲一下模板模式, 观察者模式这两个模式在 JDK 中的应用. 除此之外还会对在理论部分已经讲过的一些模式在 JDK 中的应用做一个汇总, 带你一块回忆复习一下.
# 1.模板模式在Collections类中的应用
前面提到, 策略, 模板, 职责链三个模式常用在框架的设计中, 提供框架的扩展点, 让框架使用者, 在不修改框架源码的情况下, 基于扩展点定制化框架的功能. Java 中的 Collections 类的 sort() 函数就是利用了模板模式的这个扩展特性.
首先看下 Collections.sort() 函数是如何使用的. 一个示例代码如下. 这个代码实现了按照不同的排序方式(按照年龄从小到大, 按照名字字母序从小到大, 按照成绩从大到小)对 students 数组进行排序.
public class Demo {
public static void main(String[] args) {
List<Student> students = new ArrayList<>();
students.add(new Student("Alice", 19, 89.0f));
students.add(new Student("Peter", 20, 78.0f));
students.add(new Student("Leo", 18, 99.0f));
Collections.sort(students, new AgeAscComparator());
print(students);
Collections.sort(students, new NameAscComparator());
print(students);
Collections.sort(students, new ScoreDescComparator());
print(students);
}
public static void print(List<Student> students) {
for (Student s : students) {
System.out.println(s.getName() + " " + s.getAge() + " " + s.getScore());
}
}
public static class AgeAscComparator implements Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.getAge() - o2.getAge();
}
}
public static class NameAscComparator implements Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.getName().compareTo(o2.getName());
}
}
public static class ScoreDescComparator implements Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
if (Math.abs(o1.getScore() - o2.getScore()) < 0.001) {
return 0;
} else if (o1.getScore() < o2.getScore()) {
return 1;
} else {
return -1;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
结合这个例子再来看下, 为什么说 Collections.sort() 函数用到了模板模式?
Collections.sort() 实现了对集合的排序. 为了扩展性, 它将其中 "比较大小" 这部分逻辑, 委派给用户来实现. 如果把比较大小这部分逻辑看作整个排序逻辑的其中一个步骤, 那就可以把它看作模板模式. 不过从代码实现的角度来看, 它看起来有点类似之前讲过的 JdbcTemplate, 并不是模板模式的经典代码实现, 而是基于 Callback 回调机制来实现的.
不过, 在其他资料中, 我还看到有人说, Collections.sort() 使用的是策略模式. 这样的说法也不是没有道理的. 如果并不把 "比较大小" 看作排序逻辑中的一个步骤, 而是看作一种算法或者策略, 那就可以把它看作一种策略模式的应用.
不过, 这也不是典型的策略模式, 前面讲到, 在典型的策略模式中, 策略模式分为策略的定义, 创建, 使用这三部分. 策略通过工厂模式来创建, 并且在程序运行期间, 根据配置, 用户输入, 计算结果等这些不确定因素, 动态决定使用哪种策略. 而在 Collections.sort() 函数中, 策略的创建并非通过工厂模式, 策略的使用也非动态确定.
# 2.观察者模式在JDK中的应用
在讲到观察者模式的时候, 重点讲解了 Google Guava 的 EventBus 框架, 它提供了观察者模式的骨架代码. 使用 EventBus, 不需要从零开始开发观察者模式. 实际上, Java JDK 也提供了观察者模式的简单框架实现. 在平时的开发中, 如果不希望引入 Google Guava 开发库, 可以直接使用 Java 语言本身提供的这个框架类.
不过, 它比 EventBus 要简单多了, 只包含两个类: java.util.Observable 和 java.util.Observer. 前者是被观察者, 后者是观察者. 它们的代码实现也非常简单, 为了方便你查看, 我直接 copy-paste 到了这里.
public interface Observer {
void update(Observable o, Object arg);
}
public class Observable {
private boolean changed = false;
private Vector<Observer> obs;
public Observable() {
obs = new Vector<>();
}
public synchronized void addObserver(Observer o) {
if (o == null)
throw new NullPointerException();
if (!obs.contains(o)) {
obs.addElement(o);
}
}
public synchronized void deleteObserver(Observer o) {
obs.removeElement(o);
}
public void notifyObservers() {
notifyObservers(null);
}
public void notifyObservers(Object arg) {
Object[] arrLocal;
synchronized (this) {
if (!changed)
return;
arrLocal = obs.toArray();
clearChanged();
}
for (int i = arrLocal.length-1; i>=0; i--)
((Observer)arrLocal[i]).update(this, arg);
}
public synchronized void deleteObservers() {
obs.removeAllElements();
}
protected synchronized void setChanged() {
changed = true;
}
protected synchronized void clearChanged() {
changed = false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
对于 Observable, Observer 的代码实现, 大部分都很好理解, 重点来看其中的两个地方. 一个是 changed 成员变量, 另一个是 notifyObservers() 函数.
先来看 changed 成员变量.
它用来表明被观察者(Observable)有没有状态更新. 当有状态更新时, 需要手动调用 setChanged() 函数, 将 changed 变量设置为 true, 这样才能在调用 notifyObservers() 函数的时候, 真正触发观察者(Observer)执行 update() 函数. 否则即便调用了 notifyObservers() 函数, 观察者的 update() 函数也不会被执行.
也就是说, 当通知观察者被观察者状态更新的时候, 需要依次调用 setChanged() 和 notifyObservers() 两个函数, 单独调用 notifyObservers() 函数是不起作用的. 你觉得这样的设计是不是多此一举呢? 这个问题留给你思考.
再来看 notifyObservers() 函数.
为了保证在多线程环境下, 添加, 移除, 通知观察者三个操作之间不发生冲突, Observable 类中的大部分函数都通过 synchronized 加了锁, 不过也有特例, notifyObservers() 这函数就没有加 synchronized 锁. 这是为什么呢? 在 JDK 的代码实现中, notifyObservers() 函数是如何保证跟其他函数操作不冲突的呢? 这种加锁方法是否存在问题? 又存在什么问题呢?
notifyObservers() 函数之所以没有像其他函数那样, 一把大锁加在整个函数上, 主要还是出于性能的考虑.
notifyObservers() 函数依次执行每个观察者的 update() 函数, 每个 update() 函数执行的逻辑提前未知, 有可能会很耗时. 如果在 notifyObservers() 函数上加 synchronized 锁, notifyObservers() 函数持有锁的时间就有可能会很长, 这就会导致其他线程迟迟获取不到锁, 影响整个 Observable 类的并发性能.
例如 Vector 类不是线程安全的, 在多线程环境下, 同时添加, 删除, 遍历 Vector 类对象中的元素, 会出现不可预期的结果. 所以在 JDK 的代码实现中, 为了避免直接给 notifyObservers() 函数加锁而出现性能问题, JDK 采用了一种折中的方案. 这个方案有点类似于之前讲过的让迭代器支持 "快照" 的解决方案.
在 notifyObservers() 函数中, 先拷贝一份观察者列表, 赋值给函数的局部变量, 由于局部变量是线程私有的, 并不在线程间共享. 这个拷贝出来的线程私有的观察者列表就相当于一个快照. 遍历快照, 逐一执行每个观察者的 update() 函数. 而这个遍历执行的过程是在快照这个局部变量上操作的, 不存在线程安全问题, 不需要加锁. 所以, 只需要对拷贝创建快照的过程加锁, 加锁的范围减少了很多, 并发性能提高了.
为什么说这是一种折中的方案呢? 这是因为, 这种加锁方法实际上是存在一些问题的. 在创建好快照之后, 添加, 删除观察者都不会更新快照, 新加入的观察者就不会被通知到, 新删除的观察者仍然会被通知到. 这种权衡是否能接受完全看你的业务场景. 实际上, 这种处理方式也是多线程编程中减小锁粒度, 提高并发性能的常用方法.
# 3.单例模式在Runtime类中的应用
JDK 中 java.lang.Runtime 类就是一个单例类. 之前讲到 Callback 回调的时候, 添加 shutdown hook 就是通过这个类来实现的.
每个 Java 应用在运行时会启动一个 JVM 进程, 每个 JVM 进程都只对应一个 Runtime 实例, 用于查看 JVM 状态以及控制 JVM 行为. 进程内唯一, 所以比较适合设计为单例. 在编程的时候, 不能自己去实例化一个 Runtime 对象, 只能通过 getRuntime() 静态方法来获得.
Runtime 类的的代码实现如下所示. 这里面只包含部分相关代码, 其他代码做了省略. 从代码中, 也可以看出, 它使用了最简单的饿汉式的单例实现方式.
/**
* Every Java application has a single instance of class
* <code>Runtime</code> that allows the application to interface with
* the environment in which the application is running. The current
* runtime can be obtained from the <code>getRuntime</code> method.
* <p>
* An application cannot create its own instance of this class.
*
* @author unascribed
* @see java.lang.Runtime#getRuntime()
* @since JDK1.0
*/
public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
//....
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 4.其他模式在JDK中的应用汇总
在讲解理论部分的时候, 已经讲过很多模式在 Java JDK 中的应用了. 这里一块再回顾一下.
在讲到模板模式的时候, 结合 Java Servlet, JUnit TestCase, Java InputStream, Java AbstractList 四个例子, 来具体讲解了它的两个作用: 扩展性和复用性.
在讲到享元模式的时候, 讲到 Integer 类中的 -128~127 之间的整型对象是可以复用的, 还讲到 String 类型中的常量字符串也是可以复用的. 这些都是享元模式的经典应用.
在讲到职责链模式的时候, 讲到Java Servlet 中的 Filter 就是通过职责链来实现的, 同时还对比了 Spring 中的 interceptor. 实际上, 拦截器, 过滤器这些功能绝大部分都是采用职责链模式来实现的.
在讲到的迭代器模式的时候, 重点剖析了 Java 中 Iterator 迭代器的实现, 手把手带你实现了一个针对线性数据结构的迭代器.
重点回顾
这两节课主要剖析了 JDK 中用到的几个经典设计模式, 其中重点剖析的有: 工厂模式, 建造者模式, 装饰器模式, 适配器模式, 模板模式, 观察者模式, 除此之外, 我们还汇总了其他模式在 JDK 中的应用, 比如: 单例模式, 享元模式, 职责链模式, 迭代器模式.
实际上, 源码都很简单, 理解起来都不难, 都没有跳出之前讲解的理论知识的范畴. 学习的重点并不是表面上去理解, 记忆某某类用了某某设计模式, 而是了解反复强调的一点, 也是标题中突出的一点, 在真实的项目开发中, 如何灵活应用设计模式, 做到活学活用, 能够根据具体的场景, 需求, 做灵活的设计和实现上的调整. 这也是模式新手和老手的最大区别.
# 78-开源实战二(上):从Unix开源开发学习应对大型复杂项目开发
软件开发的难度无外乎两点, 一是技术难, 意思是说, 代码量不一定多, 但要解决的问题比较难, 需要用到一些比较深的技术解决方案或者算法, 不是靠 "堆人" 就能搞定的, 比如自动驾驶, 图像识别, 高性能消息队列等; 二是复杂度, 意思是说, 技术不难, 但项目很庞大, 业务复杂, 代码量多, 参与开发的人多, 比如物流系统, 财务系统等. 第一点涉及细分专业的领域知识, 跟专栏要讲的设计, 编码无关, 所以重点来讲第二点, 如何应对软件开发的复杂度.
简单的 "hello world" 程序, 谁都能写得出来. 几千行的代码谁都能维护得了. 但当代码超过几万行, 十几万, 甚至几十万行, 上百万行的时候, 软件的复杂度就会呈指数级增长. 这种情况下, 不仅仅要求程序运行得了, 运行得正确, 还要求代码看得懂, 维护得了. 实际上, 复杂度不仅仅体现在代码本身, 还体现在协作研发上, 如何管理庞大的团队, 来进行有条不紊地协作开发, 也是一个很复杂的难题.
如何应对复杂软件开发? Unix 开源项目就是一个值得学习的例子.
Unix 从 1969 年诞生, 一直演进至今, 代码量有几百万行, 如此庞大的项目开发, 能够如此完美的协作开发, 并且长期维护, 保持足够的代码质量, 这里面有很多成功的经验可以借鉴. 所以接下来, 就以 Unix 开源项目的开发为引子, 通过下面三个话题, 详细地讲讲应对复杂软件开发的方法论. 希望这些经验能为你所用, 在今后面对复杂项目开发的时候, 能让你有条不紊, 有章可循地从容应对.
- 从设计原则和思想的角度来看, 如何应对庞大而复杂的项目开发?
- 从研发管理和开发技巧的角度来看, 如何应对庞大而复杂的项目开发?
- 聚焦在 Code Review 上来看, 如何通过 Code Reviwe 保持项目的代码质量?
# 1.封装与抽象
在 Unix, Linux 系统中, 有一句经典的话, "Everything is a file", 翻译成中文就是 "一切皆文件". 这句话的意思就是, 在 Unix, Linux 系统中, 很多东西都被抽象成 "文件" 这样一个概念, 比如 Socket, 驱动, 硬盘, 系统信息等. 它们使用文件系统的路径作为统一的命名空间(namespace) , 使用统一的 read, write 标准函数来访问.
比如, 要查看 CPU 的信息, 在 Linux 系统中, 只需要打开 /proc/cpuinfo, 就能查看到相应的信息. 除此之外, 还可以通过查看 /proc/uptime 文件, 了解系统运行了多久, 查看 /proc/version 了解系统的内核版本等.
实际上, "一切皆文件" 就体现了封装和抽象的设计思想.
封装了不同类型设备的访问细节, 抽象为统一的文件访问方式, 更高层的代码就能基于统一的访问方式, 来访问底层不同类型的设备. 这样做的好处是, 隔离底层设备访问的复杂性. 统一的访问方式能够简化上层代码的编写, 并且代码更容易复用.
除此之外, 抽象和封装还能有效控制代码复杂性的蔓延, 将复杂性封装在局部代码中, 隔离实现的易变性, 提供简单, 统一的访问接口, 让其他模块来使用, 其他模块基于抽象的接口而非具体的实现编程, 代码会更加稳定.
# 2.分层与模块化
前面也提到, 模块化是构建复杂系统的常用手段.
对于像 Unix 这样的复杂系统, 没有人能掌控所有的细节. 之所以能开发出如此复杂的系统, 并且能维护得了, 最主要的原因就是将系统划分成各个独立的模块, 比如进程调度, 进程通信, 内存管理, 虚拟文件系统, 网络接口等模块. 不同的模块之间通过接口来进行通信, 模块之间耦合很小, 每个小的团队聚焦于一个独立的高内聚模块来开发, 最终像搭积木一样, 将各个模块组装起来, 构建成一个超级复杂的系统.
除此之外, Unix, Linux 等大型系统之所以能做到几百, 上千人有条不紊地协作开发, 也归功于模块化做得好. 不同的团队负责不同的模块开发, 这样即便在不了解全部细节的情况下, 管理者也能协调各个模块, 让整个系统有效运转.
实际上, 除了模块化之外, 分层也是常用来架构复杂系统的方法.
计算机领域的任何问题都可以通过增加一个间接的中间层来解决, 这本身就体现了分层的重要性. 比如, Unix 系统也是基于分层开发的, 它可以大致上分为三层, 分别是内核, 系统调用, 应用层. 每一层都对上层封装实现细节, 暴露抽象的接口来调用. 而且任意一层都可以被重新实现, 不会影响到其他层的代码.
面对复杂系统的开发, 要善于应用分层技术, 把容易复用, 跟具体业务关系不大的代码, 尽量下沉到下层, 把容易变动, 跟具体业务强相关的代码, 尽量上移到上层.
# 3.基于接口通信
刚刚讲了分层, 模块化, 那不同的层之间, 不同的模块之间, 是如何通信的呢? 一般来讲都是通过接口调用. 在设计模块(module)或者层(layer)要暴露的接口的时候, 要学会隐藏实现, 接口从命名到定义都要抽象一些, 尽量少涉及具体的实现细节.
比如, Unix 系统提供的 open() 文件操作函数, 底层实现非常复杂, 涉及权限控制, 并发控制, 物理存储, 但用起来却非常简单. 除此之外, 因为 open() 函数基于抽象而非具体的实现来定义, 所以在改动 open() 函数的底层实现的时候, 并不需要改动依赖它的上层代码.
# 4.高内聚,松耦合
高内聚, 松耦合是一个比较通用的设计思想, 内聚性好, 耦合少的代码, 能让我们在修改或者阅读代码的时候, 聚集到在一个小范围的模块或者类中, 不需要了解太多其他模块或类的代码, 使焦点不至于太发散, 也就降低了阅读和修改代码的难度. 而且, 因为依赖关系简单, 耦合小, 修改代码不会牵一发而动全身, 代码改动比较集中, 引入 bug 的风险也就减少了很多.
实际上, 刚刚讲到的很多方法, 比如封装, 抽象, 分层, 模块化, 基于接口通信, 都能有效地实现代码的高内聚, 松耦合. 反过来, 代码的高内聚, 松耦合, 也就意味着, 抽象, 封装做到比较到位, 代码结构清晰, 分层和模块化合理, 依赖关系简单, 那代码整体的质量就不会太差. 即便某个具体的类或者模块设计得不怎么合理, 代码质量不怎么高, 影响的范围也是非常有限的. 可以聚焦于这个模块或者类做相应的小型重构. 而相对于代码结构的调整, 这种改动范围比较集中的小型重构的难度就小多了.
# 5.为扩展而设计
越是复杂项目, 越要在前期设计上多花点时间. 提前思考项目中未来可能会有哪些功能需要扩展, 提前预留好扩展点, 以便在未来需求变更的时候, 在不改动代码整体结构的情况下, 轻松地添加新功能.
做到代码可扩展, 需要代码满足开闭原则. 特别是像 Unix 这样的开源项目, 有 n 多人参与开发, 任何人都可以提交代码到代码库中. 代码满足开闭原则, 基于扩展而非修改来添加新功能, 最小化, 集中化代码改动, 避免新代码影响到老代码, 降低引入 bug 的风险.
除了满足开闭原则, 做到代码可扩展, 前面也提到很多方法, 比如封装和抽象, 基于接口编程等. 识别出代码可变部分和不可变部分, 将可变部分封装起来, 隔离变化, 提供抽象化的不可变接口, 供上层系统使用. 当具体的实现发生变化的时候, 只需要基于相同的抽象接口, 扩展一个新的实现, 替换掉老的实现即可, 上游系统的代码几乎不需要修改.
# 6.KISS首要原则
简单清晰, 可读性好, 是任何大型软件开发要遵循的首要原则. 只要可读性好, 即便扩展性不好, 顶多就是多花点时间, 多改动几行代码的事情. 但如果可读性不好, 连看都看不懂, 那就不是多花时间可以解决得了的了. 如果你对现有代码的逻辑似懂非懂, 抱着尝试的心态去修改代码, 引入 bug 的可能性就会很大.
不管是自己还是团队, 在参与大型项目开发的时候, 要尽量避免过度设计, 过早优化, 在扩展性和可读性有冲突的时候, 或者在两者之间权衡, 模棱两可的时候, 应该选择遵循 KISS 原则, 首选可读性.
# 7.最小惊奇原则
《Unix 编程艺术》一书中提到一个 Unix 的经典设计原则, 叫 "最小惊奇原则", 英文是 "The Least Surprise Priciple". 实际上, 这个原则等同于 "遵守开发规范", 意思是, 在做设计或者编码的时候要遵守统一的开发规范, 避免反直觉的设计. 这一点在前面的编码规范部分也讲到过.
遵从统一的编码规范, 所有的代码都像一个人写出来的, 能有效地减少阅读干扰. 在大型软件开发中, 参与开发的人员很多, 如果每个人都按照自己的编码习惯来写代码, 那整个项目的代码风格就会千奇百怪, 这个类是这种编码风格, 另一个类又是另外一种风格. 在阅读的时候, 要不停地切换去适应不同的编码风格, 可读性就变差了. 所以对于大型项目的开发来说, 要特别重视遵守统一的开发规范.
重点回顾
今天主要从设计原则和思想的角度, 也可以说是从设计开发的角度, 来学习如何应对复杂软件开发. 我总计了 7 点我认为比较重要的. 这 7 点前面都详细讲过, 如果你对哪块理解得不够清楚, 可以回过头去再看下. 这 7 点分别是:
- 封装与抽象
- 分层与模块化
- 基于接口通信
- 高内聚, 松耦合
- 为扩展而设计
- KISS 首要原则
- 最小惊奇原则
当然, 这 7 点之间并不是相互独立的, 有几点是互相支持的, 比如 "高内聚, 松耦合" 与抽象封装, 分层模块化, 基于接口通信. 有几点是互相冲突的, 比如 KISS 原则与为扩展而设计, 这都需要根据实际情况去权衡.
# 79-开源实战二(中):从Unix开源开发学习应对大型复杂项目开发
项目越复杂, 代码量越多, 参与开发人员越多, 开发维护时间越长, 就越是要重视代码质量. 代码质量下降会导致项目研发困难重重, 比如: 开发效率低, 招了很多人, 天天加班, 出活却不多; 线上 bug 频发, 查找 bug 困难, 领导发飙, 中层束手无策, 工程师抱怨不断.
导致代码质量不高的原因有很多, 比如: 代码无注释, 无文档, 命名差, 层次结构不清晰, 调用关系混乱, 到处 hardcode, 充斥着各种临时解决方案等等. 那怎么才能时刻保证代码质量呢? 当然, 首要的是团队技术素质要过硬, 能够适当地利用设计原则, 思想, 模式编写高质量的代码. 除此之外, 还有一些外在的方法可循.
今天就从研发管理和开发技巧的角度来看下, 面对大型复杂项目的开发, 如何长期保证代码质量, 让代码长期可维护.
# 1.吹毛求疵般地执行编码规范
严格执行代码规范, 可以使一个项目乃至整个公司的代码具有完全统一的风格, 就像同一个人编写的. 而且命名良好的变量, 函数, 类和注释, 也可以提高代码的可读性. 编码规范不难掌握, 关键是要严格执行. 在 Code Review 时, 我们一定要严格要求, 看到不符合规范的代码, 一定要指出并要求修改.
但据我了解, 实际情况往往事与愿违. 虽然大家都知道优秀的代码规范是怎样的, 但在具体写代码的过程中, 执行得却不好. 我觉得, 这种情况产生的主要原因还是不够重视. 很多人会觉得, 一个变量或者函数命名成啥样, 关系并不大. 所以命名时不推敲, 注释也不写, Code Review 的时候也都一副事不关己的心态, 觉得没必要太抠细节. 日积月累, 项目代码就会变得越来越差. 所以这里还是要强调一下, 细节决定成败, 代码规范的严格执行极为关键.
# 2.编写高质量的单元测试
单元测试是最容易执行且对提高代码质量见效最快的方法之一. 高质量的单元测试不仅仅要求测试覆盖率要高, 还要求测试的全面性, 除了测试正常逻辑的执行之外, 还要重点, 全面地测试异常下的执行情况. 毕竟代码出问题的地方大部分都发生在异常, 边界条件下.
对于大型复杂项目, 集成测试, 黑盒测试都很难测试全面, 因为组合爆炸, 穷举所有测试用例的成本很高, 几乎是不可能的. 单元测试就是很好的补充. 它可以在类, 函数这些细粒度的代码层面, 保证代码运行无误. 底层细粒度的代码 bug 少了, 组合起来构建而成的整个系统的 bug 也就相应的减少了.
# 3.不流于形式的Code Review
如果说很多工程师对单元测试不怎么重视, 那对 Code Review 就是不怎么接受. 之前跟一些同行聊起 Code Review 的时候, 很多人的反应是, 这玩意儿不可能很好地执行, 形式大于效果, 纯粹是浪费时间. 是的, 即便 Code Review 做得再流畅, 也是要花时间的. 所以在业务开发任务繁重的时候, Code Review 往往会流于形式, 虎头蛇尾, 效果确实不怎么好.
但并不能因此就否定 Code Review 本身的价值. 在 Google, Facebook 等外企中, Code Review 应用得非常成功, 已经成为了开发流程中不可或缺的一部分. 所以, 要想真正发挥 Code Review 的作用, 关键还是要执行到位, 不能流于形式.
# 4.开发未动,文档先行
对大部分工程师来说, 编写技术文档是件挺让人 "反感" 的事情. 一般来讲, 在开发某个系统或者重要模块或者功能之前, 应该先写技术文档, 然后, 发送给同组或者相关同事审查, 在审查没有问题的情况下再开发. 这样能够保证事先达成共识, 开发出来的东西不至于走样. 而且当开发完成之后, 进行 Code Review 的时候, 代码审查者通过阅读开发文档, 也可以快速理解代码.
除此之外, 对于团队和公司来讲, 文档是重要的财富. 对新人熟悉代码或任务的交接等, 技术文档很有帮助. 而且, 作为一个规范化的技术团队, 技术文档是一种摒弃作坊式开发和个人英雄主义的有效方法, 是保证团队有效协作的途径.
# 5.持续重构,重构,重构
个人比较反对平时不注重代码质量, 堆砌烂代码, 实在维护不了了就大刀阔斧地重构甚至重写. 有的时候, 因为项目代码太多, 重构很难做到彻底, 最后又搞出来一个四不像的怪物, 这就更麻烦了!
优秀的代码或架构不是一开始就能设计好的, 就像优秀的公司或产品也都是迭代出来的. 我们无法 100% 预见未来的需求, 也没有足够的精力, 时间, 资源为遥远的未来买单. 所以, 随着系统的演进, 重构是不可避免的.
虽然刚刚说不支持大刀阔斧, 推倒重来式的大重构, 但持续的小重构还是比较提倡的. 它也是时刻保证代码质量, 防止代码腐化的有效手段. 换句话说, 不要等到问题堆得太多了再去解决, 要时刻有人对代码整体质量负责任, 平时没事就改改代码. 千万不要觉得重构代码就是浪费时间, 不务正业!
特别是一些业务开发团队, 有时候为了快速完成一个业务需求, 只追求速度, 到处 hard code, 在完全不考虑非功能性需求, 代码质量的情况下, 堆砌烂代码. 实际上, 这种情况还是比较常见的. 不过没关系, 等你有时间了, 一定要记着重构, 不然烂代码越堆越多, 总有一天代码会变得无法维护.
# 6.对项目与团队进行拆分
团队人比较少, 比如十几个人的时候, 代码量不多, 不超过 10 万行, 怎么开发, 怎么管理都没问题, 大家互相都比较了解彼此做的东西. 即便代码质量太差了, 大不了把它重写一遍. 但对于一个大型项目来说, 参与开发的人员会比较多, 代码量很大, 有几十万, 甚至几百万行代码, 有几十, 甚至几百号人同时开发维护, 那研发管理就变得极其重要.
面对大型复杂项目, 不仅仅需要对代码进行拆分, 还需要对研发团队进行拆分. 上一节讲到了一些代码拆分的方法, 比如模块化, 分层等. 同理也可以把大团队拆成几个小团队. 每个小团队对应负责一个小的项目(模块, 微服务等), 这样每个团队负责的项目包含的代码都不至于很多, 也不至于出现代码质量太差无法维护的情况.
# 81-开源实战三(上):借Google Guava学习发现和开发通用功能模块
上几节拿 Unix 这个超级大型开源软件的开发作为引子, 从代码设计编写和研发管理两个角度, 讲了如何应对大型复杂项目的开发. 接下来再讲一下 Google 开源的 Java 开发库 Google Guava.
Google Guava 是一个非常成功, 非常受欢迎的开源项目. 在 Java 项目开发中应用很广泛. 当然, 这里并不会讲解其中的每个类, 接口如何使用, 而是重点讲解其背后蕴含的设计思想, 使用的设计模式. 内容比较多, 分三节来讲解.
- 第一节课, 对 Google Guava 做一个简单介绍, 并借此讲一下如何开发一个通用的功能模块.
- 第二节课, 讲 Google Guava 中用到的几种设计模式, 会补充讲解之前没有讲到的 Immutable 模式.
- 第三节课, 借 Google Guava 补充讲解三大编程范式中的最后一个: 函数式编程.
# 1.Google Guava介绍
Google Guava 是 Google 公司内部 Java 开发工具库的开源版本. Google 内部的很多 Java 项目都在使用它. 它提供了一些 JDK 没有提供的功能, 以及对 JDK 已有功能的增强功能. 其中就包括: 集合(Collections), 缓存(Caching), 原生类型支持(Primitives Support), 并发库(Concurrency Libraries), 通用注解(Common Annotation), 字符串处理(Strings Processing), 数学计算(Math), I/O, 事件总线(EventBus)等等.

JDK 的全称是 Java Development Kit. 它本身就是 Java 提供的工具类库. 那既然有了 JDK, 为什么 Google 还要开发一套新的类库 Google Guava? 是否是重复早轮子? 两者的差异化在哪里?
带着这个问题, 结合 Google Guava, 来学习如何在业务开发中, 发现通用的功能模块, 以及如何将它开发成类库, 框架或者功能组件.
# 2.如何发现通用的功能模块?
很多人觉得做业务开发没有挑战, 实际上, 做业务开发也会涉及很多非业务功能的开发, 比如前面讲到的 ID 生成器, 性能计数器, EventBus, DI 容器, 以及后面会讲到的限流框架, 幂等框架, 灰度组件. 关键在于要有善于发现, 善于抽象的能力, 并且具有扎实的设计, 开发能力, 能够发现这些非业务的, 可复用的功能点, 并且从业务逻辑中将其解耦抽象出来, 设计并开发成独立的功能模块.
在我看来, 在业务开发中, 跟业务无关的通用功能模块, 常见的一般有三类: 类库(library), 框架(framework), 功能组件(component) 等.
其中, Google Guava 属于类库, 提供一组 API 接口. EventBus, DI 容器属于框架, 提供骨架代码, 能让业务开发人员聚焦在业务开发部分, 在预留的扩展点里填充业务代码. ID 生成器, 性能计数器属于功能组件, 提供一组具有某一特殊功能的 API 接口, 有点类似类库, 但更加聚焦和重量级, 比如 ID 生成器有可能会依赖 Redis 等外部系统, 不像类库那么简单.
前面提到的限流, 幂等, 灰度, 到底是属于框架还是功能组件, 要视具体情况而定. 如果业务代码嵌套在它们里面开发, 那就可以称它们为框架. 如果它们只是开放 API 接口, 供业务系统调用, 那就可以称它们为组件. 不过, 叫什么没有太大关系, 不必太深究概念.
那如何发现项目中的这些通用的功能模块呢?
实际上, 不管是类库, 框架还是功能组件, 这些通用功能模块有两个最大的特点: 复用和业务无关. Google Guava 就是一个典型的例子.
如果没有复用场景, 那也就没有了抽离出来, 设计成独立模块的必要了. 如果与业务有关又可复用, 大部分情况下会设计成独立的系统(比如微服务), 而不是类库, 框架或功能组件. 所以, 如果你负责开发的代码, 与业务无关并且可能会被复用, 那就可以考虑将它独立出来, 开发成类库, 框架, 功能组件等通用功能模块.
稍微补充一下, 这里讲的是, 在业务开发中, 如何发现通用的功能模块. 除了业务开发团队之外, 很多公司还有一些基础架构团队, 架构开发团队, 他们除了开发类库, 框架, 功能组件之外, 也会开发一些通用的系统, 中间件, 比如, Google MapReduce, Kafka 消息中间件, 监控系统, 分布式调用链追踪系统等.
# 3.如何开发通用的功能模块?
当发现了通用功能模块的开发需求之后, 如何将它设计开发成一个优秀的类库, 框架或功能组件呢? 今天不讲具体的开发技巧, 具体的开发技巧在后面 Spring 开源实战那部分, 会讲到一些, 今天打算先讲一些更普适的开发思想.
作为通用的类库, 框架, 功能组件, 就是希望开发出来之后, 不仅仅是自己项目使用, 还能用在其他团队的项目中, 甚至可以开源出来供更多人所用, 这样才能发挥它更大的价值, 构建自己的影响力.
所以, 对于这些类库, 框架, 功能组件的开发, 不能闭门造车, 要把它们当作 "产品" 来开发. 这个产品是一个 "技术产品", 目标用户是 "程序员", 解决的是他们的 "开发痛点". 要多换位思考, 站在用户的角度上, 来想他们到底想要什么样的功能.
对于一个技术产品来说, 尽管 Bug 少, 性能好等技术指标至关重要, 但是否易用, 易集成, 易插拔, 文档是否全面, 是否容易上手等, 这些产品素质也非常重要, 甚至还能起到决定性作用. 往往就是这些很容易忽视, 不被重视的东西, 会决定一个技术产品是否能在众多的同类中脱颖而出.
具体到 Google Guava, 它是一个开发类库, 目标用户是 Java 开发工程师, 解决用户主要痛点是, 相对于 JDK, 提供更多的工具类, 简化代码编写, 比如它提供了用来判断 null 值的 Preconditions 类; Splitter, Joiner, CharMatcher 字符串处理类; Multisets, Multimaps, Tables 等更丰富的 Collections 类等等.
它的优势有这样几点: 第一, 由 Google 管理, 长期维护, 经过充分的单元测试, 代码质量有保证; 第二, 可靠, 性能好, 高度优化, 比如 Google Guava 提供的 Immutable Collections 要比 JDK 的 unmodifiableCollection 性能好; 第三, 全面, 完善的文档, 容易上手, 学习成本低, 你可以去看下它的 Github Wiki.
刚刚讲的是 "产品意识", 再来讲讲 "服务意识". 我经常在团队中说, 如果你开发的东西是提供给其他团队用的, 你一定要有 "服务意识". 对于程序员来说, 这点可能比 "产品意识"更加欠缺.
首先, 从心态上, 别的团队使用我们开发出来的技术产品, 我们要学会感谢. 这点很重要. 心态不同了, 做起事来就会有微妙的不同. 其次, 除了写代码, 还要有抽出大量时间答疑, 充当客服角色的心理准备. 有了这个心理准备, 别的团队的人在问你问题的时候, 你也就不会很烦了.
相对于业务代码来说, 开发这种被多处复用的通用代码, 对代码质量的要求更高些, 因为这些项目的影响面更大, 一旦出现 bug, 会牵连很多系统或其他项目. 特别是如果要把项目开源, 影响就更大了. 所以, 这类项目的代码质量一般都很好, 开发这类项目对代码能力的锻炼更有大. 这也是我经常推荐别人通过阅读著名开源项目代码, 参与开源项目来提高技术的原因.
具体到 Google Guava, 它是 Google 员工开发的, 单元测试很完善, 注释写得很规范, 代码写得也很好, 可以说是学习 Google 开发经验的一手资料, 建议如果有时间的话, 可以认真阅读一下它的代码.
尽管开发这些通用功能模块更加锻炼技术, 但也不要重复造轮子, 能复用的尽量复用. 而且在项目中, 如果想把所有的通用功能都开发为独立的类库, 框架, 功能组件, 这就有点大动干戈了, 有可能会得不到领导的支持. 毕竟从项目中将这部分通用功能独立出来开发, 比起作为项目的一部分来开发, 会更加耗时.
所以, 权衡一下的话, 建议初期先把这些通用的功能作为项目的一部分来开发. 不过, 在开发的时候, 做好模块化工作, 将它们尽量跟其他模块划清界限, 通过接口, 扩展点等松耦合的方式跟其他模式交互. 等到时机成熟了, 再将它从项目中剥离出来. 因为之前模块化做的好, 耦合程度低, 剥离出来的成本也就不会很高.
# 82-开源实战三(中):剖析Google Guava中用到的几种设计模式
上一节通过 Google Guava 这样一个优秀的开源类库, 讲解了如何在业务开发中, 发现跟业务无关, 可以复用的通用功能模块, 并将它们从业务代码中抽离出来, 设计开发成独立的类库, 框架或功能组件.
今天再来学习一下, Google Guava 中用到的几种经典设计模式: Builder 模式, Wrapper 模式, 以及之前没讲过的 Immutable 模式.
# 1.Builder模式在Guava中的应用
在项目开发中经常用到缓存. 它可以非常有效地提高访问速度. 常用的缓存系统有 Redis, Memcache 等. 但是, 如果要缓存的数据比较少, 就完全没必要在项目中独立部署一套缓存系统. 多引入一个系统就要多维护一个系统, 项目维护的成本就会变高.
取而代之, 可以在系统内部构建一个内存缓存, 跟系统集成在一起开发, 部署. 那如何构建内存缓存呢? 可以基于 JDK 提供的类, 比如 HashMap, 从零开始开发内存缓存. 不过, 从零开发一个内存缓存, 涉及的工作就会比较多, 比如缓存淘汰策略等. 为简化开发, 就可以使用 Google Guava 提供的现成的缓存工具类 com.google.common.cache.*.
使用 Google Guava 来构建内存缓存非常简单, 这里写了一个例子贴在了下面.
public class CacheDemo {
public static void main(String[] args) {
Cache<String, String> cache = CacheBuilder.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
cache.put("key1", "value1");
String value = cache.getIfPresent("key1");
System.out.println(value);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
从上面的代码可以发现, Cache 对象是通过 CacheBuilder 这样一个 Builder 类来创建的. 为什么要由 Builder 类来创建 Cache 对象呢?
构建一个缓存, 需要配置 n 多参数, 比如过期时间, 淘汰策略, 最大缓存大小等等. 相应地, Cache 类就会包含 n 多成员变量. 需要在构造函数中, 设置这些成员变量的值, 但又不是所有的值都必须设置, 设置哪些值由用户来决定. 为了满足这个需求, 就需要定义多个包含不同参数列表的构造函数. 为了避免构造函数的参数列表过长, 不同的构造函数过多, 一般有两种解决方案. 其中一个解决方案是使用 Builder 模式; 另一个方案是先通过无参构造函数创建对象, 然后再通过 setXXX() 方法来逐一设置需要的设置的成员变量.
那为什么 Guava 选择第一种而不是第二种解决方案呢? 使用第二种解决方案是否也可以呢? 答案是不行的. 至于为什么, 看下源码就清楚了. 这里把 CacheBuilder 类中的 build() 函数摘抄到了下面.
public <K1 extends K, V1 extends V> Cache<K1, V1> build() {
this.checkWeightWithWeigher();
this.checkNonLoadingCache();
return new LocalManualCache(this);
}
private void checkNonLoadingCache() {
Preconditions.checkState(this.refreshNanos <mark> -1L, "refreshAfterWrite requires a LoadingCache");
}
private void checkWeightWithWeigher() {
if (this.weigher </mark> null) {
Preconditions.checkState(this.maximumWeight == -1L, "maximumWeight requires weigher");
} else if (this.strictParsing) {
Preconditions.checkState(this.maximumWeight != -1L, "weigher requires maximumWeight");
} else if (this.maximumWeight == -1L) {
logger.log(Level.WARNING, "ignoring weigher specified without maximumWeight");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
看了代码, 你是否有了答案呢? 实际上, 答案在讲 Builder 模式的时候已经讲过了. 现在再结合 CacheBuilder 的源码重新说下.
必须使用 Builder 模式的主要原因是, 在真正构造 Cache 对象的时候, 必须做一些必要的参数校验, 也就是 build() 函数中前两行代码要做的工作. 如果采用无参默认构造函数加 setXXX() 方法的方案, 这两个校验就无处安放了. 而不经过校验, 创建的 Cache 对象有可能是不合法, 不可用的.
# 2.Wrapper模式在Guava中的应用
在 Google Guava 的 collection 包路径下, 有一组以 Forwarding 开头命名的类. 我截了这些类中的一部分贴到了下面.

这组 Forwarding 类很多, 但实现方式都很相似. 我摘抄了其中的 ForwardingCollection 中的部分代码到这里, 然后思考下这组 Forwarding 类是干什么用的.
@GwtCompatible
public abstract class ForwardingCollection<E> extends ForwardingObject implements Collection<E> {
protected ForwardingCollection() {
}
protected abstract Collection<E> delegate();
public Iterator<E> iterator() {
return this.delegate().iterator();
}
public int size() {
return this.delegate().size();
}
@CanIgnoreReturnValue
public boolean removeAll(Collection<?> collection) {
return this.delegate().removeAll(collection);
}
public boolean isEmpty() {
return this.delegate().isEmpty();
}
public boolean contains(Object object) {
return this.delegate().contains(object);
}
@CanIgnoreReturnValue
public boolean add(E element) {
return this.delegate().add(element);
}
@CanIgnoreReturnValue
public boolean remove(Object object) {
return this.delegate().remove(object);
}
public boolean containsAll(Collection<?> collection) {
return this.delegate().containsAll(collection);
}
@CanIgnoreReturnValue
public boolean addAll(Collection<? extends E> collection) {
return this.delegate().addAll(collection);
}
@CanIgnoreReturnValue
public boolean retainAll(Collection<?> collection) {
return this.delegate().retainAll(collection);
}
public void clear() {
this.delegate().clear();
}
public Object[] toArray() {
return this.delegate().toArray();
}
//...省略部分代码...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
光看 ForwardingCollection 的代码实现, 你可能想不到它的作用. 再给点提示, 举一个它的用法示例, 如下所示:
public class AddLoggingCollection<E> extends ForwardingCollection<E> {
private static final Logger logger = LoggerFactory.getLogger(AddLoggingCollection.class);
private Collection<E> originalCollection;
public AddLoggingCollection(Collection<E> originalCollection) {
this.originalCollection = originalCollection;
}
@Override
protected Collection delegate() {
return this.originalCollection;
}
@Override
public boolean add(E element) {
logger.info("Add element: " + element);
return this.delegate().add(element);
}
@Override
public boolean addAll(Collection<? extends E> collection) {
logger.info("Size of elements to add: " + collection.size());
return this.delegate().addAll(collection);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
结合源码和示例, 我想你应该知道这组 Forwarding 类的作用了吧?
在上面的代码中, AddLoggingCollection 是基于代理模式实现的一个代理类, 它在原始 Collection 类的基础之上, 针对 "add" 相关的操作, 添加了记录日志的功能.
前面讲到, 代理模式, 装饰器, 适配器模式可以统称为 Wrapper 模式, 通过 Wrapper 类二次封装原始类. 它们的代码实现也很相似, 都可以通过组合的方式, 将 Wrapper 类的函数实现委托给原始类的函数来实现.
public interface Interf {
void f1();
void f2();
}
public class OriginalClass implements Interf {
@Override
public void f1() { //...
}
@Override
public void f2() { //...
}
}
public class WrapperClass implements Interf {
private OriginalClass oc;
public WrapperClass(OriginalClass oc) {
this.oc = oc;
}
@Override
public void f1() {
//...附加功能...
this.oc.f1();
//...附加功能...
}
@Override
public void f2() {
this.oc.f2();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
实际上, 这个 ForwardingCollection 类是一个 "默认 Wrapper 类" 或者叫 "缺省 Wrapper 类". 这类似于在装饰器模式那一节中讲到的 FilterInputStream 缺省装饰器类.
如果不使用这个 ForwardinCollection 类, 而是让 AddLoggingCollection 代理类直接实现 Collection 接口, 那 Collection 接口中的所有方法, 都要在 AddLoggingCollection 类中实现一遍, 而真正需要添加日志功能的只有 add() 和 addAll() 两个函数, 其他函数的实现, 都只是类似 Wrapper 类中 f2() 函数的实现那样, 简单地委托给原始 collection 类对象的对应函数.
为了简化 Wrapper 模式的代码实现, Guava 提供一系列缺省的 Forwarding 类. 用户在实现自己的 Wrapper 类的时候, 基于缺省的 Forwarding 类来扩展, 就可以只实现自己关心的方法, 其他不关心的方法使用缺省 Forwarding 类的实现, 就像 AddLoggingCollection 类的实现那样.
# 3.Immutable模式在Guava中的应用
Immutable 模式, 中文叫作不变模式, 它并不属于经典的 23 种设计模式, 但作为一种较常用的设计思路, 可以总结为一种设计模式来学习. 之前只稍微提到过 Immutable 模式, 但没有独立的拿出来详细讲解, 这里借 Google Guava 再补充讲解一下.
一个对象的状态在对象创建之后就不再改变, 这就是所谓的不变模式. 其中涉及的类就是不变类(Immutable Class), 对象就是不变对象(Immutable Object). 在 Java 中, 最常用的不变类就是 String 类, String 对象一旦创建之后就无法改变.
不变模式可以分为两类, 一类是普通不变模式, 另一类是深度不变模式(Deeply Immutable Pattern). 普通的不变模式指的是, 对象中包含的引用对象是可以改变的. 如果不特别说明, 通常所说的不变模式, 指的就是普通的不变模式. 深度不变模式指的是, 对象包含的引用对象也不可变. 它们两个之间的关系, 有点类似之前讲过的浅拷贝和深拷贝之间的关系. 举个例子来进一步解释一下, 代码如下所示:
// 普通不变模式
public class User {
private String name;
private int age;
private Address addr;
public User(String name, int age, Address addr) {
this.name = name;
this.age = age;
this.addr = addr;
}
// 只有getter方法, 无setter方法...
}
public class Address {
private String province;
private String city;
public Address(String province, String city) {
this.province = province;
this.city= city;
}
// 有getter方法, 也有setter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 深度不变模式
public class User {
private String name;
private int age;
private Address addr;
public User(String name, int age, Address addr) {
this.name = name;
this.age = age;
this.addr = addr;
}
// 只有getter方法, 无setter方法...
}
public class Address {
private String province;
private String city;
public Address(String province, String city) {
this.province = province;
this.city= city;
}
// 只有getter方法, 无setter方法..
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在某个业务场景下, 如果一个对象符合创建之后就不会被修改这个特性, 那就可以把它设计成不变类. 显式地强制它不可变, 这样能避免意外被修改. 那如何将一个不变类呢? 方法很简单, 只要这个类满足: 所有的成员变量都通过构造函数一次性设置好, 不暴露任何 set 等修改成员变量的方法. 除此之外, 因为数据不变, 所以不存在并发读写问题, 因此不变模式常用在多线程环境下, 来避免线程加锁. 所以不变模式也常被归类为多线程设计模式.
接下来, 来看一种特殊的不变类, 那就是不变集合. Google Guava 针对集合类(Collection, List, Set, Map...)提供了对应的不变集合类(ImmutableCollection, ImmutableList, ImmutableSet, ImmutableMap...) . 刚刚讲过, 不变模式分为两种, 普通不变模式和深度不变模式. Google Guava 提供的不变集合类属于前者, 也就是说, 集合中的对象不会增删, 但是对象的成员变量(或叫属性值)是可以改变的.
实际上, Java JDK 也提供了不变集合类(UnmodifiableCollection, UnmodifiableList, UnmodifiableSet, UnmodifiableMap...). 那它跟 Google Guava 提供的不变集合类的区别在哪里呢? 举个例子就明白了, 代码如下所示:
public class ImmutableDemo {
public static void main(String[] args) {
List<String> originalList = new ArrayList<>();
originalList.add("a");
originalList.add("b");
originalList.add("c");
List<String> jdkUnmodifiableList = Collections.unmodifiableList(originalList);
List<String> guavaImmutableList = ImmutableList.copyOf(originalList);
// jdkUnmodifiableList.add("d"); // 抛出UnsupportedOperationException
// guavaImmutableList.add("d"); // 抛出UnsupportedOperationException
originalList.add("d");
print(originalList); // a b c d
print(jdkUnmodifiableList); // a b c d
print(guavaImmutableList); // a b c
}
private static void print(List<String> list) {
for (String s : list) {
System.out.print(s + " ");
}
System.out.println();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
重点回顾
今天学习了 Google Guava 中都用到的几个设计模式: Builder 模式, Wrapper 模式, Immutable 模式. 还是那句话, 内容本身不重要, 也不用死记硬背 Google Guava 的某某类用到了某某设计模式. 实际上, 我想通过这些源码的剖析, 传达给你下面这些东西.
在阅读源码的时候, 要问问自己, 为什么它要这么设计? 不这么设计行吗? 还有更好的设计吗? 实际上, 很多人缺少这种 "质疑" 精神, 特别是面对权威(经典书籍, 著名源码, 权威人士)的时候.
我觉得我本人是最不缺质疑精神的一个人, 我喜欢挑战权威, 喜欢以理服人. 就好比在今天的讲解中, 我把 ForwardingCollection 等类理解为缺省 Wrapper 类, 可以用在装饰器, 代理, 适配器三种 Wrapper 模式中, 简化代码编写. 如果你去看 Google Guava 在 GitHub 上的 Wiki, 你会发现, 它对 ForwardingCollection 类的理解跟我是不一样的. 它把 ForwardingCollection 类单纯地理解为缺省的装饰器类, 只用在装饰器模式中. 我个人觉得我的理解更加好些, 不知道你怎么认为呢?
除此之外, 前面也讲到, 学习设计模式能让你更好的阅读源码, 理解源码. 如果没有之前的理论学习, 那对于很多源码的阅读, 可能都只停留在走马观花的层面上, 根本学习不到它的精髓. 这就好比今天讲到的 CacheBuilder. 我想大部分人都知道它是利用了 Builder 模式, 但如果对 Builder 模式没有深入的了解, 很少人能讲清楚为什么要用 Builder 模式, 不用构造函数加 set 方法的方式来实现.
# 83-开源实战三(下):借Google Guava学习三大编程范式中的函数式编程
现在主流的编程范式主要有三种, 面向过程, 面向对象和函数式编程. 在理论部分, 已经详细讲过前两种了. 今天再借机会讲讲剩下的一种, 函数式编程.
函数式编程并非一个很新的东西, 早在 50 多年前就已经出现了. 近几年, 函数式编程越来越被人关注, 出现了很多新的函数式编程语言, 比如 Clojure, Scala, Erlang 等. 一些非函数式编程语言也加入了很多特性, 语法, 类库来支持函数式编程, 比如 Java, Python, Ruby, JavaScript 等. 除此之外, Google Guava 也有对函数式编程的增强功能.
函数式编程因其编程的特殊性, 仅在科学计算, 数据处理, 统计分析等领域, 才能更好地发挥它的优势, 所以我个人觉得, 它并不能完全替代更加通用的面向对象编程范式. 但是作为一种补充, 它也有很大存在, 发展和学习的意义.
# 1.到底什么是函数式编程?
函数式编程的英文翻译是 Functional Programming. 什么是函数式编程呢?
前面讲面向过程, 面向对象编程并没有严格的官方定义. 在当时的讲解中只是给出了自己总结的定义. 而且当时给出的定义也只是对两个范式主要特性的总结, 并不是很严格. 实际上, 函数式编程也是如此, 也没有一个严格的官方定义. 所以接下来就从特性上来告诉你, 什么是函数式编程.
严格上来讲, 函数式编程中的 "函数", 并不是指编程语言中的 "函数 "概念, 而是指数学 "函数" 或者 "表达式"(比如, y=f(x)). 不过, 在编程实现的时候, 对于数学 "函数" 或 "表达式", 一般习惯性地将它们设计成函数. 所以, 如果不深究的话, 函数式编程中的 "函数" 也可以理解为编程语言中的 "函数".
每个编程范式都有自己独特的地方, 这就是它们会被抽象出来作为一种范式的原因. 面向对象编程最大的特点是: 以类, 对象作为组织代码的单元以及它的四大特性. 面向过程编程最大的特点是: 以函数作为组织代码的单元, 数据与方法相分离. 那函数式编程最独特的地方又在哪里呢?
实际上, 函数式编程最独特的地方在于它的编程思想. 函数式编程认为, 程序可以用一系列数学函数或表达式的组合来表示. 函数式编程是程序面向数学的更底层的抽象, 将计算过程描述为表达式. 不过, 这样说你肯定会有疑问, 真的可以把任何程序都表示成一组数学表达式吗?
理论上讲是可以的. 但并不是所有的程序都适合这么做. 函数式编程有它自己适合的应用场景, 比如科学计算, 数据处理, 统计分析等. 在这些领域, 程序往往比较容易用数学表达式来表示, 比起非函数式编程, 实现同样的功能, 函数式编程可以用很少的代码就能搞定. 但对于强业务相关的大型业务系统开发来说, 非要将它抽象成数学表达式, 硬要用函数式编程来实现, 显然是自讨苦吃. 相反, 在这种应用场景下, 面向对象编程更加合适, 写出来的代码更加可读, 可维护.
刚刚讲的是函数式编程的编程思想, 如果再具体到编程实现, 函数式编程跟面向过程编程一样, 也是以函数作为组织代码的单元. 不过, 它跟面向过程编程的区别在于, 它的函数是无状态的. 何为无状态? 简单点讲就是, 函数内部涉及的变量都是局部变量, 不会像面向对象编程那样, 共享类成员变量, 也不会像面向过程编程那样, 共享全局变量. 函数的执行结果只与入参有关, 跟其他任何外部变量无关. 同样的入参, 不管怎么执行, 得到的结果都是一样的. 这实际上就是数学函数或数学表达式的基本要求. 举个例子来简单解释一下.
// 有状态函数: 执行结果依赖b的值是多少, 即便入参相同, 多次执行函数, 函数的返回值有可能不同, 因为b值有可能不同.
int b;
int increase(int a) {
return a + b;
}
2
3
4
5
// 无状态函数: 执行结果不依赖任何外部变量值, 只要入参相同, 不管执行多少次, 函数的返回值就相同
int increase(int a, int b) {
return a + b;
}
2
3
4
这里稍微总结一下, 不同的编程范式之间并不是截然不同的, 总是有一些相同的编程规则. 比如, 不管是面向过程, 面向对象还是函数式编程, 它们都有变量, 函数的概念, 最顶层都要有 main 函数执行入口, 来组装编程单元(类, 函数等). 只不过, 面向对象的编程单元是类或对象, 面向过程的编程单元是函数, 函数式编程的编程单元是无状态函数.
# 2.Java对函数式编程的支持
前面讲到, 实现面向对象编程不一定非得使用面向对象编程语言, 同理实现函数式编程也不一定非得使用函数式编程语言. 现在很多面向对象编程语言, 也提供了相应的语法, 类库来支持函数式编程.
接下来就看下 Java 对函数式编程的支持, 借机加深一下对函数式编程的理解. 先来看下面这样一段非常典型的 Java 函数式编程的代码.
public class FPDemo {
public static void main(String[] args) {
Optional<Integer> result = Stream.of("f", "ba", "hello")
.map(s -> s.length())
.filter(l -> l <= 3)
.max((o1, o2) -> o1-o2);
System.out.println(result.get()); // 输出2
}
}
2
3
4
5
6
7
8
9
这段代码的作用是从一组字符串数组中, 过滤出长度小于等于 3 的字符串, 并且求得这其中的最大长度.
如果你不了解 Java 函数式编程的语法, 看了上面的代码或许会有些懵, 主要的原因是, Java 为函数式编程引入了三个新的语法概念: Stream 类, Lambda 表达式和函数接口(Functional Inteface). Stream 类用来支持通过"."级联多个函数操作的代码编写方式; 引入 Lambda 表达式的作用是简化代码编写; 函数接口的作用是让我们可以把函数包裹成函数接口, 来实现把函数当做参数一样来使用(Java 不像 C 一样支持函数指针, 可以把函数直接当参数来使用).
首先, 来看下 Stream 类.
假设要计算这样一个表达式: (3-1)*2+5. 如果按照普通的函数调用的方式写出来, 就是下面这个样子:
add(multiply(subtract(3,1),2),5);
不过, 这样编写代码看起来会比较难理解, 换个更易读的写法, 如下所示:
subtract(3,1).multiply(2).add(5);
在 Java 中, "." 表示调用某个对象的方法. 为了支持上面这种级联调用方式, 让每个函数都返回一个通用的类型: Stream 类对象. 在 Stream 类上的操作有两种: 中间操作和终止操作. 中间操作返回的仍然是 Stream 类对象, 而终止操作返回的是确定的值结果.
再来看之前的例子. 对代码做了注释解释. 其中, map, filter 是中间操作, 返回 Stream 类对象, 可以继续级联其他操作; max 是终止操作, 返回的不是 Stream 类对象, 无法再继续往下级联处理了.
public class FPDemo {
public static void main(String[] args) {
Optional<Integer> result = Stream.of("f", "ba", "hello") // of返回Stream<String>对象
.map(s -> s.length()) // map返回Stream<Integer>对象
.filter(l -> l <= 3) // filter返回Stream<Integer>对象
.max((o1, o2) -> o1 - o2); // max终止操作: 返回Optional<Integer>
System.out.println(result.get()); // 输出2
}
}
2
3
4
5
6
7
8
9
其次, 再来看下 Lambda 表达式.
前面讲到, Java 引入 Lambda 表达式的主要作用是简化代码编写. 实际上, 也可以不用 Lambda 表达式来书写例子中的代码. 拿其中的 map 函数来举例说明一下.
下面有三段代码, 第一段代码展示了 map 函数的定义, 实际上, map 函数接收的参数是一个 Function 接口, 也就是待会儿要讲到的函数接口. 第二段代码展示了 map 函数的使用方式. 第三段代码是针对第二段代码用 Lambda 表达式简化之后的写法. 实际上, Lambda 表达式在 Java 中只是一个语法糖而已, 底层是基于函数接口来实现的, 也就是第二段代码展示的写法.
// Stream中map函数的定义:
public interface Stream<T> extends BaseStream<T, Stream<T>> {
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
//...省略其他函数...
}
// Stream中map的使用方法:
Stream.of("fo", "bar", "hello").map(new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return s.length();
}
});
// 用Lambda表达式简化后的写法:
Stream.of("fo", "bar", "hello").map(s -> s.length());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Lambda 表达式包括三部分: 输入, 函数体, 输出. 表示出来的话就是下面这个样子:
(a, b) -> { 语句1; 语句2; ...; return 输出; } // a,b是输入参数
实际上, Lambda 表达式的写法非常灵活. 刚刚给出的是标准写法, 还有很多简化写法. 比如如果输入参数只有一个, 可以省略 (), 直接写成 a->{...}; 如果没有入参, 可以直接将输入和箭头都省略掉, 只保留函数体; 如果函数体只有一个语句, 那可以将 {} 省略掉; 如果函数没有返回值, return 语句就可以不用写了.
如果把之前例子中的 Lambda 表达式, 全部替换为函数接口的实现方式, 就是下面这样子的. 代码是不是多了很多?
Optional<Integer> result = Stream.of("f", "ba", "hello")
.map(s -> s.length())
.filter(l -> l <= 3)
.max((o1, o2) -> o1 - o2);
// 还原为函数接口的实现方式
Optional<Integer> result2 = Stream.of("fo", "bar", "hello")
.map(new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return s.length();
}
})
.filter(new Predicate<Integer>() {
@Override
public boolean test(Integer l) {
return l <= 3;
}
})
.max(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
最后来看下函数接口.
实际上, 上面一段代码中的 Function, Predicate, Comparator 都是函数接口. C 语言支持函数指针, 它可以把函数直接当变量来使用. 但是, Java 没有函数指针这样的语法. 所以它通过函数接口, 将函数包裹在接口中, 当作变量来使用.
实际上, 函数接口就是接口. 不过, 它也有自己特别的地方, 那就是要求只包含一个未实现的方法. 因为只有这样, Lambda 表达式才能明确知道匹配的是哪个接口. 如果有两个未实现的方法, 并且接口入参, 返回值都一样, 那 Java 在翻译 Lambda 表达式的时候, 就不知道表达式对应哪个方法了.
下面把 Java 提供的 Function, Predicate 这两个函数接口的源码, 摘抄过来贴到了下面.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t); // 只有这一个未实现的方法
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t); // 只有这一个未实现的方法
default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}
default Predicate<T> negate() {
return (t) -> !test(t);
}
default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}
static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 3.Guava对函数式编程的增强
如果你是 Google Guava 的设计者, 对于 Java 函数式编程, Google Guava 还能做些什么呢?
颠覆式创新是很难的. 不过可以进行一些补充, 一方面, 可以增加 Stream 类上的操作(类似 map, filter, max 这样的终止操作和中间操作), 另一方面, 也可以增加更多的函数接口(类似 Function, Predicate 这样的函数接口). 实际上, 还可以设计一些类似 Stream 类的新的支持级联操作的类. 这样使用 Java 配合 Guava 进行函数式编程会更加方便.
但是, 跟预期的相反, Google Guava 并没有提供太多函数式编程的支持, 仅仅封装了几个遍历集合操作的接口, 代码如下所示:
Iterables.transform(Iterable, Function);
Iterators.transform(Iterator, Function);
Collections.transfrom(Collection, Function);
Lists.transform(List, Function);
Maps.transformValues(Map, Function);
Multimaps.transformValues(Mltimap, Function);
...
Iterables.filter(Iterable, Predicate);
Iterators.filter(Iterator, Predicate);
Collections2.filter(Collection, Predicate);
...
2
3
4
5
6
7
8
9
10
11
从 Google Guava 的 GitHub Wiki 中发现, Google 对于函数式编程的使用还是很谨慎的, 认为过度地使用函数式编程, 会导致代码可读性变差, 强调不要滥用. 这跟前面对函数式编程的观点是一致的. 所以在函数式编程方面, Google Guava 并没有提供太多的支持.
之所以对遍历集合操作做了优化, 主要是因为函数式编程一个重要的应用场景就是遍历集合. 如果不使用函数式编程, 只能 for 循环, 一个一个的处理集合中的数据. 使用函数式编程, 可以大大简化遍历集合操作的代码编写, 一行代码就能搞定, 而且在可读性方面也没有太大损失.
# 84-开源实战四(上):剖析Spring框架中蕴含的经典设计思想或原则
在 Java 世界里, Spring 框架已经几乎成为项目开发的必备框架. 作为如此优秀和受欢迎的开源项目, 它是源码阅读的首选材料之一, 不管是设计思想, 还是代码实现, 都有很多值得学习的地方. 接下来就详细讲讲 Spring 框架中蕴含的设计思想, 原则和模式. 因为内容比较多, 分三部分来讲解.
- 第一部分, 讲解 Spring 框架中蕴含的经典设计思想或原则.
- 第二部分, 讲解 Spring 框架中用来支持扩展的两种设计模式.
- 第三部分, 总结罗列 Spring 框架中用到的其他十几种设计模式.
今天就讲下第一部分: Spring 框架中蕴含的一些设计思想或原则, 这其中就包括: 约定大于配置, 低侵入松耦合, 模块化轻量级等. 这些设计思想都很通用, 掌握之后, 可以借鉴用到其他框架的开发中.
# 1.从Spring看框架的作用
如果你使用过一些框架来做开发, 就应该能感受到使用框架开发的优势. 这里稍微总结一下. 利用框架的好处有: 解耦业务和非业务开发, 让程序员聚焦在业务开发上; 隐藏复杂实现细节, 降低开发难度, 减少代码 bug; 实现代码复用, 节省开发时间; 规范化标准化项目开发, 降低学习和维护成本等等. 实际上, 如果要用一句话来总结一下的话, 那就是简化开发!
相比单纯的 CRUD 业务代码开发, 非业务代码开发要更难一些. 所以将一些非业务的通用代码开发为框架, 在项目中复用, 除了节省开发时间之外, 也降低了项目开发的难度. 除此之外, 框架经过多个项目的多次验证, 比起每个项目都重新开发, 代码的 bug 会相对少一些. 而且, 不同的项目使用相同的框架, 对于研发人员来说, 从一个项目切换到另一个项目的学习成本, 也会降低很多.
拿常见的 Web 项目开发来举例说明一下.
通过在项目中引入 Spring MVC 开发框架, 开发一个 Web 应用, 只需要创建 Controller, Service, Repository 三层类, 在其中填写相应的业务代码, 然后做些简单的配置, 告知框架 Controller, Service, Repository 类之间的调用关系, 剩下的非业务相关的工作, 比如, 对象的创建, 组装, 管理, 请求的解析, 封装, URL 与 Controller 之间的映射, 都由框架来完成. 不仅如此, 如果直接引入功能更强大的 Spring Boot, 那将应用部署到 Web 容器的工作都省掉了. Spring Boot 内嵌了 Tomcat, Jetty 等 Web 容器. 在编写完代码之后, 用一条命令就能完成项目的部署, 运行.
# 2.Spring框架蕴含的设计思想
在 Google Guava 源码讲解中, 讲到开发通用功能模块的一些比较普适的开发思想, 比如产品意识, 服务意识, 代码质量意识, 不要重复早轮子等. 今天剖析一下 Spring 框架背后的一些经典设计思想(或开发技巧) . 这些设计思想并非 Spring 独有, 都比较通用, 能借鉴应用在很多通用功能模块的设计开发中. 这也是学习 Spring 源码的价值所在.
# (1)约定优于配置
在使用 Spring 开发的项目中, 配置往往会比较复杂, 繁琐. 比如, 利用 Spring MVC 来开发 Web 应用, 需要配置每个 Controller 类以及 Controller 类中的接口对应的 URL.
如何来简化配置呢? 一般来讲, 有两种方法, 一种是基于注解, 另一种是基于约定. 这两种配置方式在 Spring 中都有用到. Spring 在最小化配置方面做得淋漓尽致, 有很多值得借鉴的地方.
基于注解的配置方式, 在指定类上使用指定的注解, 来替代集中的 XML 配置. 比如, 使用 @RequestMapping 注解, 在 Controller 类或者接口上, 标注对应的 URL; 使用 @Transaction 注解表明支持事务等.
基于约定的配置方式, 也常叫作 "约定优于配置" 或者 "规约优于配置"(Convention over Configuration). 通过约定的代码结构或者命名来减少配置. 说直白点, 就是提供配置的默认值, 优先使用默认值. 程序员只需要设置那些偏离约定的配置就可以了.
比如, 在 Spring JPA(基于 ORM 框架, JPA 规范的基础上, 封装的一套 JPA 应用框架)中, 约定类名默认跟表名相同, 属性名默认跟表字段名相同, String 类型对应数据库中的 varchar 类型, long 类型对应数据库中的 bigint 类型等等.
基于刚刚的约定, 代码中定义的 Order 类就对应数据库中的 "order" 表. 只有在偏离这一约定的时候, 例如数据库中表命名为 "order_info" 而非 "order", 才需要显示地去配置类与表的映射关系(Order 类 -> order_info 表).
实际上, 约定优于配置, 很好地体现了 "二八法则". 在平时的项目开发中, 80% 的配置使用默认配置就可以了, 只有 20% 的配置必须用户显式地去设置. 所以基于约定来配置, 在没有牺牲配置灵活性的前提下, 节省了大量编写配置的时间, 省掉了很多不动脑子的纯体力劳动, 提高了开发效率. 除此之外, 基于相同的约定来做开发, 也减少了项目的学习成本和维护成本.
# (2)低侵入,松耦合
框架的侵入性是衡量框架好坏的重要指标. 所谓低侵入指的是, 框架代码很少耦合在业务代码中. 低侵入意味着, 当要替换一个框架的时候, 对原有的业务代码改动会很少. 相反如果一个框架是高度侵入的, 代码高度侵入到业务代码中, 那替换成另一个框架的成本将非常高, 甚至几乎不可能. 这也是一些长期维护的老项目, 使用的框架, 技术比较老旧, 又无法更新的一个很重要的原因.
实际上, 低侵入是 Spring 框架遵循的一个非常重要的设计思想.
Spring 提供的 IOC 容器, 在不需要 Bean 继承任何父类或者实现任何接口的情况下, 仅仅通过配置, 就能将它们纳入进 Spring 的管理中. 如果换一个 IOC 容器, 也只是重新配置一下就可以了, 原有的 Bean 都不需要任何修改.
除此之外, Spring 提供的 AOP 功能, 也体现了低侵入的特性. 在项目中, 对于非业务功能, 比如请求日志, 数据采点, 安全校验, 事务等等, 没必要将它们侵入进业务代码中. 因为一旦侵入, 这些代码将分散在各个业务代码中, 删除, 修改的成本就变得很高. 而基于 AOP 这种开发模式, 将非业务代码集中放到切面中, 删除, 修改的成本就变得很低了.
# (3)模块化,轻量级
随着不断的发展, Spring 现在也不单单只是一个只包含 IOC 功能的小框架了, 它显然已经壮大成了一个 "平台" 或者叫 "生态", 包含了各种五花八门的功能. 尽管如此, 但它也并没有重蹈覆辙, 变成一个像 EJB 那样的庞大难用的框架. 那 Spring 是怎么做到的呢?
这就要归功于 Spring 的模块化设计思想. 如下所示是 Spring Framework 的模块和分层介绍图.

从图中可以看出, Spring 在分层, 模块化方面做得非常好. 每个模块都只负责一个相对独立的功能. 模块之间关系, 仅有上层对下层的依赖关系, 而同层之间以及下层对上层, 几乎没有依赖和耦合. 除此之外, 在依赖 Spring 的项目中, 开发者可以有选择地引入某几个模块, 而不会因为需要一个小的功能, 就被强迫引入整个 Spring 框架. 所以, 尽管 Spring Framework 包含的模块很多, 已经有二十几个, 但每个模块都非常轻量级, 都可以单独拿来使用. 正因如此, 到现在, Spring 框架仍然可以被称为是一个轻量级的开发框架.
# (4)再封装,再抽象
Spring 不仅仅提供了各种 Java 项目开发的常用功能模块, 而且还对市面上主流的中间件, 系统的访问类库, 做了进一步的封装和抽象, 提供了更高层次, 更统一的访问接口.
比如, Spring 提供了 spring-data-redis 模块, 对 Redis Java 开发类库(比如 Jedis, Lettuce)做了进一步的封装, 适配 Spring 的访问方式, 让编程访问 Redis 更加简单.
还有下节要讲的 Spring Cache, 实际上也是一种再封装, 再抽象. 它定义了统一, 抽象的 Cache 访问接口, 这些接口不依赖具体的 Cache 实现(Redis, Guava Cache, Caffeine 等) . 在项目中, 基于 Spring 提供的抽象统一的接口来访问 Cache. 这样就能在不修改代码的情况下, 实现不同 Cache 之间的切换.
除此之外, 还记得之前在模板模式中, 讲过的 JdbcTemplate 吗? 实际上, 它也是对 JDBC 的进一步封装和抽象, 为的是进一步简化数据库编程. 不仅如此, Spring 对 JDBC 异常也做了进一步的封装. 封装的数据库异常继承自 DataAccessException 运行时异常. 这类异常在开发中无需强制捕获, 从而减少了不必要的异常捕获和处理. 除此之外, Spring 封装的数据库异常, 还屏蔽了不同数据库异常的细节(比如, 不同的数据库对同一报错定义了不同的错误码), 让异常的处理更加简单.
# 85-开源实战四(中):剖析Spring框架中用来支持扩展的两种设计模式
上一节学习了 Spring 框架背后蕴藏的一些经典设计思想, 比如约定优于配置, 低侵入松耦合, 模块化轻量级等等. 可以将这些设计思想借鉴到其他框架开发中, 在大的设计层面提高框架的代码质量.
除了上一节中讲到的设计思想, 实际上, 可扩展也是大部分框架应该具备的一个重要特性. 所谓的框架可扩展, 之前也提到过, 意思就是, 框架使用者在不修改框架源码的情况下, 基于扩展点定制扩展新的功能.
常用来实现扩展特性的设计模式有: 观察者模式, 模板模式, 职责链模式, 策略模式等. 今天再剖析 Spring 框架为了支持可扩展特性用的 2 种设计模式: 观察者模式和模板模式.
# 1.观察者模式在Spring中的应用
前面讲到, Java, Google Guava 都提供了观察者模式的实现框架. Java 提供的框架比较简单, 只包含 java.util.Observable 和 java.util.Observer 两个类. Google Guava 提供的框架功能比较完善和强大: 通过 EventBus 事件总线来实现观察者模式. 实际上, Spring 也提供了观察者模式的实现框架. 今天就再来讲一讲它.
Spring 中实现的观察者模式包含三部分: Event 事件(相当于消息), Listener 监听者(相当于观察者), Publisher 发送者(相当于被观察者). 通过一个例子来看下, Spring 提供的观察者模式是怎么使用的. 代码如下所示:
// Event事件
public class DemoEvent extends ApplicationEvent {
private String message;
public DemoEvent(Object source, String message) {
super(source);
}
public String getMessage() {
return this.message;
}
}
// Listener监听者
@Component
public class DemoListener implements ApplicationListener<DemoEvent> {
@Override
public void onApplicationEvent(DemoEvent demoEvent) {
String message = demoEvent.getMessage();
System.out.println(message);
}
}
// Publisher发送者
@Component
public class DemoPublisher {
@Autowired
private ApplicationContext applicationContext;
public void publishEvent(DemoEvent demoEvent) {
this.applicationContext.publishEvent(demoEvent);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
从代码可以看出, 框架使用起来并不复杂, 主要包含三部分工作: 定义一个继承 ApplicationEvent 的事件(DemoEvent); 定义一个实现了 ApplicationListener 的监听器(DemoListener); 定义一个发送者(DemoPublisher), 发送者调用 ApplicationContext 来发送事件消息.
其中, ApplicationEvent 和 ApplicationListener 的代码实现都非常简单, 内部并不包含太多属性和方法. 实际上, 它们最大的作用是做类型标识之用(继承自 ApplicationEvent 的类是事件, 实现 ApplicationListener 的类是监听器).
public abstract class ApplicationEvent extends EventObject {
private static final long serialVersionUID = 7099057708183571937L;
private final long timestamp = System.currentTimeMillis();
public ApplicationEvent(Object source) {
super(source);
}
public final long getTimestamp() {
return this.timestamp;
}
}
2
3
4
5
6
7
8
9
10
11
12
public class EventObject implements java.io.Serializable {
private static final long serialVersionUID = 5516075349620653480L;
protected transient Object source;
public EventObject(Object source) {
if (source == null)
throw new IllegalArgumentException("null source");
this.source = source;
}
public Object getSource() {
return source;
}
public String toString() {
return getClass().getName() + "[source=" + source + "]";
}
}
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
void onApplicationEvent(E var1);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
在前面讲到观察者模式的时候提到, 观察者需要事先注册到被观察者(JDK 的实现方式)或者事件总线(EventBus 的实现方式)中. 那在 Spring 的实现中, 观察者注册到了哪里呢? 又是如何注册的呢?
其实是把观察者注册到了 ApplicationContext 对象中. 这里的 ApplicationContext 就相当于 Google EventBus 框架中的 "事件总线". 不过, 稍微提醒一下, ApplicationContext 这个类并不只是为观察者模式服务的. 它底层依赖 BeanFactory(IOC 的主要实现类), 提供应用启动, 运行时的上下文信息, 是访问这些信息的最顶层接口.
实际上, 具体到源码来说, ApplicationContext 只是一个接口, 具体的代码实现包含在它的实现类 AbstractApplicationContext 中. 我把跟观察者模式相关的代码, 摘抄到了下面. 只需要关注它是如何发送事件和注册监听者就好, 其他细节不需要细究.
public abstract class AbstractApplicationContext extends XXX{
private final Set<ApplicationListener<?>> applicationListeners;
public AbstractApplicationContext() {
this.applicationListeners = new LinkedHashSet();
//...
}
public void publishEvent(ApplicationEvent event) {
this.publishEvent(event, (ResolvableType) null);
}
public void publishEvent(Object event) {
this.publishEvent(event, (ResolvableType) null);
}
protected void publishEvent(Object event, ResolvableType eventType) {
//...
Object applicationEvent;
if (event instanceof ApplicationEvent) {
applicationEvent = (ApplicationEvent) event;
} else {
applicationEvent = new PayloadApplicationEvent(this, event);
if (eventType == null) {
eventType = ((PayloadApplicationEvent) applicationEvent).getResolvableType();
}
}
if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
} else {
this.getApplicationEventMulticaster().multicastEvent(
(ApplicationEvent) applicationEvent, eventType);
}
if (this.parent != null) {
if (this.parent instanceof AbstractApplicationContext) {
((AbstractApplicationContext) this.parent).publishEvent(event, eventType);
} else {
this.parent.publishEvent(event);
}
}
}
public void addApplicationListener(ApplicationListener<?> listener) {
Assert.notNull(listener, "ApplicationListener must not be null");
if (this.applicationEventMulticaster != null) {
this.applicationEventMulticaster.addApplicationListener(listener);
} else {
this.applicationListeners.add(listener);
}
}
public Collection<ApplicationListener<?>> getApplicationListeners() {
return this.applicationListeners;
}
protected void registerListeners() {
Iterator var1 = this.getApplicationListeners().iterator();
while (var1.hasNext()) {
ApplicationListener<?> listener = (ApplicationListener) var1.next();
this.getApplicationEventMulticaster().addApplicationListener(listener);
}
String[] listenerBeanNames = this.getBeanNamesForType(ApplicationListener.class, true, false);
String[] var7 = listenerBeanNames;
int var3 = listenerBeanNames.length;
for (int var4 = 0; var4 < var3; ++var4) {
String listenerBeanName = var7[var4];
this.getApplicationEventMulticaster().addApplicationListenerBean(listenerBeanName);
}
Set<ApplicationEvent> earlyEventsToProcess = this.earlyApplicationEvents;
this.earlyApplicationEvents = null;
if (earlyEventsToProcess != null) {
Iterator var9 = earlyEventsToProcess.iterator();
while (var9.hasNext()) {
ApplicationEvent earlyEvent = (ApplicationEvent) var9.next();
this.getApplicationEventMulticaster().multicastEvent(earlyEvent);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
从上面的代码中, 真正的消息发送, 实际上是通过 ApplicationEventMulticaster 这个类来完成的. 这个类的源码只摘抄了最关键的一部分, 也就是 multicastEvent() 这个消息发送函数. 不过, 它的代码也并不复杂, 就不多解释了. 这里稍微提示一下, 它通过线程池, 支持异步非阻塞, 同步阻塞这两种类型的观察者模式.
public void multicastEvent(ApplicationEvent event) {
this.multicastEvent(event, this.resolveDefaultEventType(event));
}
public void multicastEvent(final ApplicationEvent event, ResolvableType eventType) {
ResolvableType type = eventType != null ? eventType : this.resolveDefaultEventType(event);
Iterator var4 = this.getApplicationListeners(event, type).iterator();
while(var4.hasNext()) {
final ApplicationListener<?> listener = (ApplicationListener)var4.next();
Executor executor = this.getTaskExecutor();
if (executor != null) {
executor.execute(new Runnable() {
public void run() {
SimpleApplicationEventMulticaster.this.invokeListener(listener, event);
}
});
} else {
this.invokeListener(listener, event);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
借助 Spring 提供的观察者模式的骨架代码, 如果要在 Spring 下实现某个事件的发送和监听, 只需要做很少的工作, 定义事件, 定义监听器, 往 ApplicationContext 中发送事件就可以了, 剩下的工作都由 Spring 框架来完成. 实际上, 这也体现了 Spring 框架的扩展性, 也就是在不需要修改任何代码的情况下, 扩展新的事件和监听.
# 2.模板模式在Spring中的应用
刚刚讲的是观察者模式在 Spring 中的应用, 现在再讲下模板模式.
来看下一下经常在面试中被问到的一个问题: 请你说下 Spring Bean 的创建过程包含哪些主要的步骤. 这其中就涉及模板模式. 它也体现了 Spring 的扩展性. 利用模板模式, Spring 能让用户定制 Bean 的创建过程.
Spring Bean 的创建过程, 可以大致分为两大步: 对象的创建和对象的初始化.
对象的创建是通过反射来动态生成对象, 而不是 new 方法. 不管是哪种方式, 说白了, 总归还是调用构造函数来生成对象, 没有什么特殊的. 对象的初始化有两种实现方式. 一种是在类中自定义一个初始化函数, 并且通过配置文件, 显式地告知 Spring, 哪个函数是初始化函数. 举个例子解释一下. 如下所示, 在配置文件中, 通过 init-method 属性来指定初始化函数.
public class DemoClass {
//...
public void initDemo() {
//...初始化..
}
}
2
3
4
5
6
7
// 配置: 需要通过init-method显式地指定初始化方法
<bean id="demoBean" class="com.xzg.cd.DemoClass" init-method="initDemo"></bean>
2
这种初始化方式有一个缺点, 初始化函数并不固定, 由用户随意定义, 这就需要 Spring 通过反射, 在运行时动态地调用这个初始化函数. 而反射又会影响代码执行的性能, 那有没有替代方案呢?
Spring 提供了另外一个定义初始化函数的方法, 那就是让类实现 Initializingbean 接口. 这个接口包含一个固定的初始化函数定义(afterPropertiesSet() 函数). Spring 在初始化 Bean 的时候, 可以直接通过 bean.afterPropertiesSet() 的方式, 调用 Bean 对象上的这个函数, 而不需要使用反射来调用了. 举个例子解释一下, 代码如下所示.
public class DemoClass implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
//...初始化...
}
}
2
3
4
5
6
// 配置: 不需要显式地指定初始化方法
<bean id="demoBean" class="com.xzg.cd.DemoClass"></bean>
2
尽管这种实现方式不会用到反射, 执行效率提高了, 但业务代码(DemoClass)跟框架代码(InitializingBean)耦合在了一起. 框架代码侵入到了业务代码中, 替换框架的成本就变高了. 所以并不是太推荐这种写法.
实际上, 在 Spring 对 Bean 整个生命周期的管理中, 还有一个跟初始化相对应的过程, 那就是 Bean 的销毁过程. 在 Java 中, 对象的回收是通过 JVM 来自动完成的. 但是可以在将 Bean 正式交给 JVM 垃圾回收前, 执行一些销毁操作(比如关闭文件句柄等等).
销毁过程跟初始化过程非常相似, 也有两种实现方式. 一种是通过配置 destroy-method 指定类中的销毁函数, 另一种是让类实现 DisposableBean 接口. 因为 destroy-method, DisposableBean 跟 init-method, InitializingBean 非常相似.
实际上, Spring 针对对象的初始化过程, 还做了进一步的细化, 将它拆分成了三个小步骤: 初始化前置操作, 初始化, 初始化后置操作. 其中, 中间的初始化操作就是刚刚讲的那部分, 初始化的前置和后置操作, 定义在接口 BeanPostProcessor 中. BeanPostProcessor 的接口定义如下所示:
public interface BeanPostProcessor {
Object postProcessBeforeInitialization(Object var1, String var2) throws BeansException;
Object postProcessAfterInitialization(Object var1, String var2) throws BeansException;
}
2
3
4
再来看下, 如何通过 BeanPostProcessor 来定义初始化前置和后置操作?
只需要定义一个实现了 BeanPostProcessor 接口的处理器类, 并在配置文件中像配置普通 Bean 一样去配置就可以了. Spring 中的 ApplicationContext 会自动检测在配置文件中实现了 BeanPostProcessor 接口的所有 Bean, 并把它们注册到 BeanPostProcessor 处理器列表中. 在 Spring 容器创建 Bean 的过程中, Spring 会逐一去调用这些处理器.
通过上面的分析, 基本上弄清楚了 Spring Bean 的整个生命周期(创建加销毁). 这个过程如下图所示, 可以结合着刚刚讲解一块看下.

不过, 你可能会说, 这里哪里用到了模板模式啊? 模板模式不是需要定义一个包含模板方法的抽象模板类, 以及定义子类实现模板方法吗?
实际上, 这里的模板模式的实现, 并不是标准的抽象类的实现方式, 而是有点类似前面讲到的 Callback 回调的实现方式, 也就是将要执行的函数封装成对象(比如, 初始化方法封装成 InitializingBean 对象), 传递给模板(BeanFactory)来执行.
# 86-开源实战四(下):总结Spring框架用到的11种设计模式
上一节讲解了 Spring 中支持扩展功能的两种设计模式: 观察者模式和模板模式. 这两种模式能够帮助用户创建扩展点, 让框架的使用者在不修改源码的情况下, 基于扩展点定制化框架功能.
实际上, Spring 框架中用到的设计模式非常多, 不下十几种. 今天就总结罗列一下它们. 有些前面已经讲过的或者比较简单的, 我就点到为止. 如果有什么不是很懂的地方, 你可以通过阅读源码, 查阅之前的理论讲解, 自己去搞定它.
# 1.适配器模式在Spring中的应用
在 Spring MVC 中, 定义一个 Controller 最常用的方式是, 通过 @Controller 注解来标记某个类是 Controller 类, 通过 @RequesMapping 注解来标记函数对应的 URL. 不过, 定义一个 Controller 远不止这一种方法. 还可以通过让类实现 Controller 接口或者 Servlet 接口, 来定义一个 Controller. 针对这三种定义方式, 写了三段示例代码, 如下所示:
// 方法一: 通过@Controller, @RequestMapping来定义
@Controller
public class DemoController {
@RequestMapping("/employname")
public ModelAndView getEmployeeName() {
ModelAndView model = new ModelAndView("Greeting");<br / > model.addObject("message", "Dinesh");<br / >
return model;
}
}
2
3
4
5
6
7
8
9
// 方法二: 实现Controller接口 + xml配置文件:配置DemoController与URL的对应关系
public class DemoController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) throws Exception {
ModelAndView model = new ModelAndView("Greeting");
model.addObject("message", "Dinesh Madhwal");
return model;
}
}
2
3
4
5
6
7
8
9
// 方法三: 实现Servlet接口 + xml配置文件:配置DemoController类与URL的对应关系
public class DemoServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().write("Hello World.");
}
}
2
3
4
5
6
7
8
9
10
11
12
在应用启动的时候, Spring 容器会加载这些 Controller 类, 并且解析出 URL 对应的处理函数, 封装成 Handler 对象, 存储到 HandlerMapping 对象中. 当有请求到来的时候, DispatcherServlet 从 HanderMapping 中, 查找请求 URL 对应的 Handler, 然后调用执行 Handler 对应的函数代码, 最后将执行结果返回给客户端.
但不同方式定义的 Controller, 其函数的定义(函数名, 入参, 返回值等)是不统一的. 如上示例代码所示, 方法一中的函数的定义很随意, 不固定, 方法二中的函数定义是 handleRequest(), 方法三中的函数定义是 service()(看似是定义了 doGet(), doPost(), 实际上, 这里用到了模板模式, Servlet 中的 service() 调用了 doGet() 或 doPost() 方法, DispatcherServlet 调用的是 service() 方法). DispatcherServlet 需要根据不同类型的 Controller, 调用不同的函数. 下面是具体的伪代码:
Handler handler = handlerMapping.get(URL);
if (handler instanceof Controller) {
((Controller)handler).handleRequest(...);
} else if (handler instanceof Servlet) {
((Servlet)handler).service(...);
} else if (hanlder 对应通过注解来定义的Controller) {
// 反射调用方法...
}
2
3
4
5
6
7
8
从代码中可以看出, 这种实现方式会有很多 if-else 分支判断, 而且如果要增加一个新的 Controller 的定义方法, 就要在 DispatcherServlet 类代码中, 对应地增加一段如上伪代码所示的 if 逻辑. 这显然不符合开闭原则.
实际上, 可以利用是适配器模式对代码进行改造, 让其满足开闭原则, 能更好地支持拓展. 前面讲到, 适配器其中一个作用是 "统一多个类的接口设计". 利用适配器模式, 将不同方式定义的 Controller 类中的函数, 适配为统一的函数定义. 这样就能在 DispatcherServlet 类代码中, 移除掉 if-else 分支判断逻辑, 调用统一的函数.
刚刚讲了大致的设计思路, 再具体看下 Spring 的代码实现.
Spring 定义了统一的接口 HandlerAdapter, 并且对每种 Controller 定义了对应的适配器类. 这些适配器类包括: AnnotationMethodHandlerAdapter, SimpleControllerHandlerAdapter, SimpleServletHandlerAdapter 等. 源码我贴到了下面, 可以结合着看下.
public interface HandlerAdapter {
boolean supports(Object var1);
ModelAndView handle(HttpServletRequest var1, HttpServletResponse var2, Object var3) throws Exception;
long getLastModified(HttpServletRequest var1, Object var2);
}
2
3
4
5
// 对应实现Controller接口的Controller
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
public SimpleControllerHandlerAdapter() {
}
public boolean supports(Object handler) {
return handler instanceof Controller;
}
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return ((Controller)handler).handleRequest(request, response);
}
public long getLastModified(HttpServletRequest request, Object handler) {
return handler instanceof LastModified ? ((LastModified)handler).getLastModified(request) : -1L;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对应实现Servlet接口的Controller
public class SimpleServletHandlerAdapter implements HandlerAdapter {
public SimpleServletHandlerAdapter() {
}
public boolean supports(Object handler) {
return handler instanceof Servlet;
}
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
((Servlet)handler).service(request, response);
return null;
}
public long getLastModified(HttpServletRequest request, Object handler) {
return -1L;
}
}
// AnnotationMethodHandlerAdapter对应通过注解实现的Controller,
// 代码太多了, 就不贴在这里了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在 DispatcherServlet 类中, 就不需要区分对待不同的 Controller 对象了, 统一调用 HandlerAdapter 的 handle() 函数就可以了. 按照这个思路实现的伪代码如下所示. 这样就没有烦人的 if-else 逻辑了吧?
// 现在实现方式
HandlerAdapter handlerAdapter = handlerMapping.get(URL);
handlerAdapter.handle(...);
2
3
# 2.策略模式在Spring中的应用
前面讲到, Spring AOP 是通过动态代理来实现的. 具体到代码实现, Spring 支持两种动态代理实现方式, 一种是 JDK 提供的动态代理实现方式, 另一种是 Cglib 提供的动态代理实现方式. 前者需要被代理的类有抽象的接口定义, 后者不需要. 针对不同的被代理类, Spring 会在运行时动态地选择不同的动态代理实现方式. 这个应用场景实际上就是策略模式的典型应用场景.
前面讲过, 策略模式包含三部分, 策略的定义, 创建和使用. 接下来具体看下, 这三个部分是如何体现在 Spring 源码中的.
在策略模式中, 策略的定义这一部分很简单. 只需要定义一个策略接口, 让不同的策略类都实现这一个策略接口. 对应到 Spring 源码, AopProxy 是策略接口, JdkDynamicAopProxy, CglibAopProxy 是两个实现了 AopProxy 接口的策略类. 其中, AopProxy 接口的定义如下所示:
public interface AopProxy {
Object getProxy();
Object getProxy(ClassLoader var1);
}
2
3
4
在策略模式中, 策略的创建一般通过工厂方法来实现. 对应到 Spring 源码, AopProxyFactory 是一个工厂类接口, DefaultAopProxyFactory 是一个默认的工厂类, 用来创建 AopProxy 对象. 两者的源码如下所示:
public interface AopProxyFactory {
AopProxy createAopProxy(AdvisedSupport var1) throws AopConfigException;
}
2
3
public class DefaultAopProxyFactory implements AopProxyFactory, Serializable {
public DefaultAopProxyFactory() {
}
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
if (!config.isOptimize() && !config.isProxyTargetClass() && !this.hasNoUserSuppliedProxyInterfaces(config)) {
return new JdkDynamicAopProxy(config);
} else {
Class<?> targetClass = config.getTargetClass();
if (targetClass == null) {
throw new AopConfigException("TargetSource cannot determine target class: Either an interface or a target is required for proxy creation.");
} else {
return (AopProxy) (!targetClass.isInterface() && !Proxy.isProxyClass(targetClass) ? new ObjenesisCglibAopProxy(config) : new JdkDynamicAopProxy(config));
}
}
}
// 用来判断用哪个动态代理实现方式
private boolean hasNoUserSuppliedProxyInterfaces(AdvisedSupport config) {
Class<?>[] ifcs = config.getProxiedInterfaces();
return ifcs.length <mark> 0 || ifcs.length </mark> 1 && SpringProxy.class.isAssignableFrom(ifcs[0]);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
策略模式的典型应用场景, 一般是通过环境变量, 状态值, 计算结果等动态地决定使用哪个策略. 对应到 Spring 源码中, 可以参看刚刚给出的 DefaultAopProxyFactory 类中的 createAopProxy() 函数的代码实现. 其中第 10 行代码是动态选择哪种策略的判断条件.
# 3.组合模式在Spring中的应用
Spring Cache 提供了一套抽象的 Cache 接口. 使用它我们能够统一不同缓存实现(Redis, Google Guava...)的不同的访问方式. Spring 中针对不同缓存实现的不同缓存访问类, 都依赖这个接口, 比如: EhCacheCache, GuavaCache, NoOpCache, RedisCache, JCacheCache, ConcurrentMapCache, CaffeineCache. Cache 接口的源码如下所示:
public interface Cache {
String getName();
Object getNativeCache();
Cache.ValueWrapper get(Object var1);
<T> T get(Object var1, Class<T> var2);
<T> T get(Object var1, Callable<T> var2);
void put(Object var1, Object var2);
Cache.ValueWrapper putIfAbsent(Object var1, Object var2);
void evict(Object var1);
void clear();
public static class ValueRetrievalException extends RuntimeException {
private final Object key;
public ValueRetrievalException(Object key, Callable<?> loader, Throwable ex) {
super(String.format("Value for key '%s' could not be loaded using '%s'", key, loader), ex);
this.key = key;
}
public Object getKey() {
return this.key;
}
}
public interface ValueWrapper {
Object get();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
在实际的开发中, 一个项目有可能会用到多种不同的缓存, 比如既用到 Google Guava 缓存, 也用到 Redis 缓存. 除此之外, 同一个缓存实例, 也可以根据业务的不同, 分割成多个小的逻辑缓存单元(或者叫作命名空间).
为了管理多个缓存, Spring 还提供了缓存管理功能. 不过, 它包含的功能很简单, 主要有这样两部分: 一个是根据缓存名字(创建 Cache 对象的时候要设置 name 属性)获取 Cache 对象; 另一个是获取管理器管理的所有缓存的名字列表. 对应的 Spring 源码如下所示:
public interface CacheManager {
Cache getCache(String var1);
Collection<String> getCacheNames();
}
2
3
4
刚刚给出的是 CacheManager 接口的定义, 那如何来实现这两个接口呢? 实际上, 这就要用到了之前讲过的组合模式.
组合模式主要应用在能表示成树形结构的一组数据上. 树中的结点分为叶子节点和中间节点两类. 对应到 Spring 源码, EhCacheManager, SimpleCacheManager, NoOpCacheManager, RedisCacheManager 等表示叶子节点, CompositeCacheManager 表示中间节点. 叶子节点包含的是它所管理的 Cache 对象, 中间节点包含的是其他 CacheManager 管理器, 既可以是 CompositeCacheManager, 也可以是具体的管理器, 比如 EhCacheManager, RedisManager 等.
这里把 CompositeCacheManger 的代码贴到了下面, 可以结合着讲解一块看下. 其中, getCache(), getCacheNames() 两个函数的实现都用到了递归. 这正是树形结构最能发挥优势的地方.
public class CompositeCacheManager implements CacheManager, InitializingBean {
private final List<CacheManager> cacheManagers = new ArrayList();
private boolean fallbackToNoOpCache = false;
public CompositeCacheManager() {
}
public CompositeCacheManager(CacheManager... cacheManagers) {
this.setCacheManagers(Arrays.asList(cacheManagers));
}
public void setCacheManagers(Collection<CacheManager> cacheManagers) {
this.cacheManagers.addAll(cacheManagers);
}
public void setFallbackToNoOpCache(boolean fallbackToNoOpCache) {
this.fallbackToNoOpCache = fallbackToNoOpCache;
}
public void afterPropertiesSet() {
if (this.fallbackToNoOpCache) {
this.cacheManagers.add(new NoOpCacheManager());
}
}
public Cache getCache(String name) {
Iterator var2 = this.cacheManagers.iterator();
Cache cache;
do {
if (!var2.hasNext()) {
return null;
}
CacheManager cacheManager = (CacheManager)var2.next();
cache = cacheManager.getCache(name);
} while(cache == null);
return cache;
}
public Collection<String> getCacheNames() {
Set<String> names = new LinkedHashSet();
Iterator var2 = this.cacheManagers.iterator();
while(var2.hasNext()) {
CacheManager manager = (CacheManager)var2.next();
names.addAll(manager.getCacheNames());
}
return Collections.unmodifiableSet(names);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 4.装饰器模式在Spring中的应用
缓存一般都是配合数据库来使用的. 如果写缓存成功, 但数据库事务回滚了, 那缓存中就会有脏数据. 为了解决这个问题, 需要将缓存的写操作和数据库的写操作, 放到同一个事务中, 要么都成功, 要么都失败.
实现这样一个功能, Spring 使用到了装饰器模式. TransactionAwareCacheDecorator 增加了对事务的支持, 在事务提交, 回滚的时候分别对 Cache 的数据进行处理.
TransactionAwareCacheDecorator 实现 Cache 接口, 并且将所有的操作都委托给 targetCache 来实现, 对其中的写操作添加了事务功能. 这是典型的装饰器模式的应用场景和代码实现, 就不多作解释了.
public class TransactionAwareCacheDecorator implements Cache {
private final Cache targetCache;
public TransactionAwareCacheDecorator(Cache targetCache) {
Assert.notNull(targetCache, "Target Cache must not be null");
this.targetCache = targetCache;
}
public Cache getTargetCache() {
return this.targetCache;
}
public String getName() {
return this.targetCache.getName();
}
public Object getNativeCache() {
return this.targetCache.getNativeCache();
}
public ValueWrapper get(Object key) {
return this.targetCache.get(key);
}
public <T> T get(Object key, Class<T> type) {
return this.targetCache.get(key, type);
}
public <T> T get(Object key, Callable<T> valueLoader) {
return this.targetCache.get(key, valueLoader);
}
public void put(final Object key, final Object value) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
public void afterCommit() {
TransactionAwareCacheDecorator.this.targetCache.put(key, value);
}
});
} else {
this.targetCache.put(key, value);
}
}
public ValueWrapper putIfAbsent(Object key, Object value) {
return this.targetCache.putIfAbsent(key, value);
}
public void evict(final Object key) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
public void afterCommit() {
TransactionAwareCacheDecorator.this.targetCache.evict(key);
}
});
} else {
this.targetCache.evict(key);
}
}
public void clear() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
public void afterCommit() {
TransactionAwareCacheDecorator.this.targetCache.clear();
}
});
} else {
this.targetCache.clear();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 5.工厂模式在Spring中的应用
在 Spring 中, 工厂模式最经典的应用莫过于实现 IOC 容器, 对应的 Spring 源码主要是 BeanFactory 类和 ApplicationContext 相关类(AbstractApplicationContext, ClassPathXmlApplicationContext, FileSystemXmlApplicationContext...) .
在 Spring 中, 创建 Bean 的方式有很多种, 比如前面提到的纯构造函数, 无参构造函数加 setter 方法. 写了一个例子来说明这两种创建方式, 代码如下所示:
public class Student {
private long id;
private String name;
public Student(long id, String name) {
this.id = id;
this.name = name;
}
public void setId(long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用构造函数来创建Bean
<bean id="student" class="com.xzg.cd.Student">
<constructor-arg name="id" value="1"/>
<constructor-arg name="name" value="wangzheng"/>
</bean>
// 使用无参构造函数+setter方法来创建Bean
<bean id="student" class="com.xzg.cd.Student">
<property name="id" value="1"></property>
<property name="name" value="wangzheng"></property>
</bean>
2
3
4
5
6
7
8
9
10
11
实际上, 除了这两种创建 Bean 的方式之外, 还可以通过工厂方法来创建 Bean. 还是刚刚这个例子, 用这种方式来创建 Bean 的话就是下面这个样子:
public class StudentFactory {
private static Map<Long, Student> students = new HashMap<>();
static{
map.put(1, new Student(1,"wang"));
map.put(2, new Student(2,"zheng"));
map.put(3, new Student(3,"xzg"));
}
public static Student getStudent(long id){
return students.get(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
// 通过工厂方法getStudent(2)来创建BeanId="zheng""的Bean
<bean id="zheng" class="com.xzg.cd.StudentFactory" factory-method="getStudent">
<constructor-arg value="2"></constructor-arg><br/>
</bean>
2
3
4
# 6.其他模式在Spring中的应用
前面的几个模式在 Spring 中的应用讲解的都比较详细, 接下来的几个模式, 大部分都是之前讲过的, 这里只是简单总结一下.
SpEL, 全称叫 Spring Expression Language, 是 Spring 中常用来编写配置的表达式语言. 它定义了一系列的语法规则. 只要按照这些语法规则来编写表达式, Spring 就能解析出表达式的含义. 实际上, 这就是我们前面讲到的解释器模式的典型应用场景.
因为解释器模式没有一个非常固定的代码实现结构, 而且 Spring 中 SpEL 相关的代码也比较多, 所以这里就不阅读源码了. 如果感兴趣或者项目中正好要实现类似的功能的时候, 可以再去阅读, 借鉴它的代码实现. 代码主要集中在 spring-expresssion 这个模块下面.
前面讲到单例模式的时候, 我提到过, 单例模式有很多弊端, 比如单元测试不友好等. 应对策略就是通过 IOC 容器来管理对象, 通过 IOC 容器来实现对象的唯一性的控制. 实际上, 这样实现的单例并非真正的单例, 它的唯一性的作用范围仅仅在同一个 IOC 容器内.
除此之外, Spring 还用到了观察者模式, 模板模式, 职责链模式, 代理模式. 其中, 观察者模式, 模板模式在上一节课已经详细讲过了.
实际上, 在 Spring 中, 只要后缀带有 Template 的类, 基本上都是模板类, 而且大部分都是用 Callback 回调来实现的, 比如 JdbcTemplate, RedisTemplate 等. 剩下的两个模式在 Spring 中的应用应该人尽皆知了. 职责链模式在 Spring 中的应用是拦截器(Interceptor), 代理模式经典应用是 AOP.
# 87-开源实战五(上):MyBatis如何权衡易用性,性能和灵活性?
上几节讲到了 Spring 框架, 剖析了背后蕴含的一些通用设计思想, 以及用到的十几种设计模式. 从今天开始, 再剖析另外一个 Java 项目开发中经常用到的框架: MyBatis. 因为内容比较多, 同样也分三节课来讲解.
- 第一节课, 分析 MyBatis 如何权衡代码的易用性, 性能和灵活性.
- 第二节课, 学习如何利用职责链与代理模式实现 MyBatis Plugin.
- 第三节课, 总结罗列一下 MyBatis 框架中用到的十几种设计模式.
# 1.Mybatis和ORM框架介绍
MyBatis 是一个 ORM(Object Relational Mapping, 对象-关系映射)框架. ORM 框架主要是根据类和数据库表之间的映射关系, 帮助程序员自动实现对象与数据库中数据之间的互相转化. 说得更具体点就是, ORM 负责将程序中的对象存储到数据库中, 将数据库中的数据转化为程序中的对象. 实际上, Java 中的 ORM 框架有很多, 除了刚刚提到的 MyBatis 之外, 还有 Hibernate, TopLink 等.
在剖析 Spring 框架的时候讲到, 如果用一句话来总结框架作用的话, 那就是简化开发. MyBatis 框架也不例外. 它简化的是数据库方面的开发.
那 MyBatis 是如何简化数据库开发的呢? 前面讲到, Java 提供了 JDBC 类库来封装不同类型的数据库操作. 不过, 直接使用 JDBC 来进行数据库编程, 还是有点麻烦的. 于是, Spring 提供了 JdbcTemplate, 对 JDBC 进一步封装, 来进一步简化数据库编程. 使用 JdbcTemplate 进行数据库编程, 只需要编写跟业务相关的代码(比如, SQL 语句, 数据库中数据与对象之间的互相转化的代码), 其他流程性质的代码(比如, 加载驱动, 创建数据库连接, 创建 statement, 关闭连接, 关闭statement 等)都封装在了 JdbcTemplate 类中, 不需要重复编写.
当时, 为了展示使用 JdbcTemplate 是如何简化数据库编程的, 还举了一个查询数据库中用户信息的例子. 还是同样这个例子, 再来看下使用 MyBatis 该如何实现, 是不是比使用 JdbcTemplate 更加简单.
因为 MyBatis 依赖 JDBC 驱动, 所以在项目中使用 MyBatis, 除了需要引入 MyBatis 框架本身(mybatis.jar)之外, 还需要引入 JDBC 驱动(比如, 访问 MySQL 的 JDBC 驱动实现类库 mysql-connector-java.jar). 将两个 jar 包引入项目之后, 就可以开始编程了. 使用 MyBatis 来访问数据库中用户信息的代码如下所示:
// 1. 定义UserDO
public class UserDo {
private long id;
private String name;
private String telephone;
// 省略setter/getter方法
}
2
3
4
5
6
7
// 2. 定义访问接口
public interface UserMapper {
public UserDo selectById(long id);
}
2
3
4
// 3. 定义映射关系: UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org/DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.xzg.cd.a87.repo.mapper.UserMapper">
<select id="selectById" resultType="cn.xzg.cd.a87.repo.UserDo">
select * from user where id=#{id}
</select>
</mapper>
2
3
4
5
6
7
8
9
// 4. 全局配置文件: mybatis.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="dev">
<environment id="dev">
<transactionManager type="JDBC"></transactionManager>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.jdbc.Driver" />
<property name="url" value="jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8" />
<property name="username" value="root" />
<property name="password" value="..." />
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mapper/UserMapper.xml"/>
</mappers>
</configuration>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
需要注意的是, 在 UserMapper.xml 配置文件中, 只定义了接口和 SQL 语句之间的映射关系, 并没有显式地定义类(UserDo)字段与数据库表(user)字段之间的映射关系. 实际上, 这就体现了 "约定优于配置" 的设计原则. 类字段与数据库表字段之间使用了默认映射关系: 类字段跟数据库表中拼写相同的字段一一映射. 当然, 如果没办法做到一一映射, 也可以自定义它们之间的映射关系.
有了上面的代码和配置, 就可以像下面这样来访问数据库中的用户信息了.
public class MyBatisDemo {
public static void main(String[] args) throws IOException {
Reader reader = Resources.getResourceAsReader("mybatis.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(reader);
SqlSession session = sessionFactory.openSession();
UserMapper userMapper = session.getMapper(UserMapper.class);
UserDo userDo = userMapper.selectById(8);
//...
}
}
2
3
4
5
6
7
8
9
10
从代码中可以看出, 相对于使用 JdbcTemplate 的实现方式, 使用 MyBatis 的实现方式更加灵活. 在使用 JdbcTemplate 的实现方式中, 对象与数据库中数据之间的转化代码, SQL 语句, 是硬编码在业务代码中的. 而在使用 MyBatis 的实现方式中, 类字段与数据库字段之间的映射关系, 接口与 SQL 之间的映射关系, 是写在 XML 配置文件中的, 是跟代码相分离的, 这样会更加灵活, 清晰, 维护起来更加方便.
# 2.如何平衡易用性,性能和灵活性?
刚刚对 MyBatis 框架做了简单介绍, 接下来再对比一下另外两个框架: JdbcTemplate 和 Hibernate. 通过对比来看, MyBatis 是如何权衡代码的易用性, 性能和灵活性的.
先来看 JdbcTemplate. 相对于 MyBatis 来说, JdbcTemplate 更加轻量级. 因为它对 JDBC 只做了很简单的封装, 所以性能损耗比较少. 相对于其他两个框架来说, 它的性能最好. 但是, 它的缺点也比较明显, 那就是 SQL 与代码耦合在一起, 而且不具备 ORM 的功能, 需要自己编写代码, 解析对象跟数据库中的数据之间的映射关系. 所以, 在易用性上它不及其他两个框架.
再来看 Hibernate. 相对于 MyBatis 来说, Hibernate 更加重量级. Hibernate 提供了更加高级的映射功能, 能够根据业务需求自动生成 SQL 语句. 不需要像使用 MyBatis 那样自己编写 SQL. 因此有的时候, 也把 MyBatis 称作半自动化的 ORM 框架, 把 Hibernate 称作全自动化的 ORM 框架. 不过虽然自动生成 SQL 简化了开发, 但是毕竟是自动生成的, 没有针对性的优化. 在性能方面, 这样得到的 SQL 可能没有程序员编写得好. 同时, 这样也丧失了程序员自己编写 SQL 的灵活性.
实际上, 不管用哪种实现方式, 从数据库中取出数据并且转化成对象, 这个过程涉及的代码逻辑基本是一致的. 不同实现方式的区别, 只不过是哪部分代码逻辑放到了哪里. 有的框架提供的功能比较强大, 大部分代码逻辑都由框架来完成, 程序员只需要实现很小的一部分代码就可以了. 这样框架的易用性就更好些. 但是框架集成的功能越多, 为了处理逻辑的通用性, 就会引入更多额外的处理代码. 比起针对具体问题具体编程, 这样性能损耗就相对大一些.
所以, 粗略地讲, 有的时候, 框架的易用性和性能成对立关系. 追求易用性, 那性能就差一些. 相反, 追求性能, 易用性就差一些. 除此之外, 使用起来越简单, 那灵活性就越差.
实际上, JdbcTemplate, MyBatis, Hibernate 这几个框架也体现了刚刚说的这个规律.
JdbcTemplate 提供的功能最简单, 易用性最差, 性能损耗最少, 用它编程性能最好. Hibernate 提供的功能最完善, 易用性最好, 但相对来说性能损耗就最高了. MyBatis 介于两者中间, 在易用性, 性能, 灵活性三个方面做到了权衡. 它支撑程序员自己编写 SQL, 能够延续程序员对 SQL 知识的积累. 相对于完全黑盒子的 Hibernate, 很多程序员反倒是更加喜欢 MyBatis 这种半透明的框架. 这也提醒我们, 过度封装, 提供过于简化的开发方式, 也会丧失开发的灵活性.
# 88-开源实战五(中):如何利用职责链与代理模式实现MyBatis Plugin?
上节对 MyBatis 框架做了简单的背景介绍, 并且通过对比各种 ORM 框架, 学习了代码的易用性, 性能, 灵活性之间的关系. 一般来讲, 框架提供的高级功能越多, 那性能损耗就会越大; 框架用起来越简单, 提供越简化的使用方式, 那灵活性也就越低.
接下来的两节再学习一下 MyBatis 用到一些经典设计模式. 今天主要讲解 MyBatis Plugin. 尽管名字叫 Plugin(插件), 但它实际上跟之前讲到的 Servlet Filter(过滤器), Spring Interceptor(拦截器)类似, 设计的初衷都是为了框架的扩展性, 用到的主要设计模式都是职责链模式.
不过, 相对于 Servlet Filter 和 Spring Interceptor, MyBatis Plugin 中职责链模式的代码实现稍微有点复杂. 它是借助动态代理模式来实现的职责链. 今天就看下, 如何利用这两个模式实现 MyBatis Plugin.
# 1.MyBatis Plugin功能介绍
实际上, MyBatis Plugin 跟 Servlet Filter, Spring Interceptor 的功能是类似的, 都是在不需要修改原有流程代码的情况下, 拦截某些方法调用, 在拦截的方法调用的前后, 执行一些额外的代码逻辑. 它们的唯一区别在于拦截的位置是不同的. Servlet Filter 主要拦截 Servlet 请求, Spring Interceptor 主要拦截 Spring 管理的 Bean 方法(比如 Controller 类的方法等), 而 MyBatis Plugin 主要拦截的是 MyBatis 在执行 SQL 的过程中涉及的一些方法.
MyBatis Plugin 使用起来比较简单, 通过一个例子来快速看下.
假设需要统计应用中每个 SQL 的执行耗时, 如果使用 MyBatis Plugin 来实现的话, 只需要定义一个 SqlCostTimeInterceptor 类, 让它实现 MyBatis 的 Interceptor 接口, 并且在 MyBatis 的全局配置文件中, 简单声明一下这个插件就可以了. 具体的代码和配置如下所示:
@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})})
public class SqlCostTimeInterceptor implements Interceptor {
private static Logger logger = LoggerFactory.getLogger(SqlCostTimeInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object target = invocation.getTarget();
long startTime = System.currentTimeMillis();
StatementHandler statementHandler = (StatementHandler) target;
try {
return invocation.proceed();
} finally {
long costTime = System.currentTimeMillis() - startTime;
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
logger.info("执行 SQL: [ {} ]执行耗时[ {} ms]", sql, costTime);
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
System.out.println("插件配置的信息: " + properties);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- MyBatis全局配置文件: mybatis-config.xml -->
<plugins>
<plugin interceptor="com.xzg.cd.a88.SqlCostTimeInterceptor">
<property name="someProperty" value="100"/>
</plugin>
</plugins>
2
3
4
5
6
因为待会会详细地介绍 MyBatis Plugin 的底层实现原理, 所以这里暂时不对上面的代码做详细地解释. 现在只重点看下 @Intercepts 注解这一部分.
不管是拦截器, 过滤器还是插件, 都需要明确地标明拦截的目标方法. @Intercepts 注解实际上就是起了这个作用. 其中, @Intercepts 注解又可以嵌套 @Signature 注解. 一个 @Signature 注解标明一个要拦截的目标方法. 如果要拦截多个方法, 可以像例子中那样, 编写多条 @Signature 注解.
@Signature 注解包含三个元素: type, method, args. 其中, type 指明要拦截的类, method 指明方法名, args 指明方法的参数列表. 通过指定这三个元素, 就能完全确定一个要拦截的方法.
默认情况下, MyBatis Plugin 允许拦截的方法有下面这样几个:

为什么默认允许拦截的是这样几个类的方法呢?
MyBatis 底层是通过 Executor 类来执行 SQL 的. Executor 类会创建 StatementHandler, ParameterHandler, ResultSetHandler 三个对象, 并且, 首先使用 ParameterHandler 设置 SQL 中的占位符参数, 然后使用 StatementHandler 执行 SQL 语句, 最后使用 ResultSetHandler 封装执行结果. 所以, 只需要拦截 Executor, ParameterHandler, ResultSetHandler, StatementHandler 这几个类的方法, 基本上就能满足对整个 SQL 执行流程的拦截了.
实际上, 除了统计 SQL 的执行耗时, 利用 MyBatis Plugin, 还可以做很多事情, 比如分库分表, 自动分页, 数据脱敏, 加密解密等等.
# 2.MyBatis Plugin的设计与实现
刚刚简单介绍了 MyBatis Plugin 是如何使用的. 现在再剖析一下源码, 看看如此简洁的使用方式, 底层是如何实现的, 隐藏了哪些复杂的设计.
相对于 Servlet Filter, Spring Interceptor 中职责链模式的代码实现, MyBatis Plugin 的代码实现还是蛮有技巧的, 因为它是借助动态代理来实现职责链的.
职责链模式的实现一般包含处理器(Handler)和处理器链(HandlerChain) 两部分. 这两个部分对应到 Servlet Filter 的源码就是 Filter 和 FilterChain, 对应到 Spring Interceptor 的源码就是 HandlerInterceptor 和 HandlerExecutionChain, 对应到 MyBatis Plugin 的源码就是 Interceptor 和 InterceptorChain. 除此之外, MyBatis Plugin 还包含另外一个非常重要的类: Plugin. 它用来生成被拦截对象的动态代理.
集成了 MyBatis 的应用在启动的时候, MyBatis 框架会读取全局配置文件(前面例子中的 mybatis-config.xml 文件), 解析出 Interceptor(也就是例子中的 SqlCostTimeInterceptor), 并且将它注入到 Configuration 类的 InterceptorChain 对象中. 这部分逻辑对应到源码如下所示:
public class XMLConfigBuilder extends BaseBuilder {
// 解析配置
private void parseConfiguration(XNode root) {
try {
// 省略部分代码...
pluginElement(root.evalNode("plugins")); // 解析插件
} catch (Exception e) {
throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
}
}
// 解析插件
private void pluginElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
String interceptor = child.getStringAttribute("interceptor");
Properties properties = child.getChildrenAsProperties();
// 创建Interceptor类对象
Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
// 调用Interceptor上的setProperties()方法设置properties
interceptorInstance.setProperties(properties);
// 下面这行代码会调用InterceptorChain.addInterceptor()方法
configuration.addInterceptor(interceptorInstance);
}
}
}
}
// Configuration类的addInterceptor()方法的代码如下所示
public void addInterceptor(Interceptor interceptor) {
interceptorChain.addInterceptor(interceptor);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
再来看 Interceptor 和 InterceptorChain 这两个类的代码, 如下所示. Interceptor 的 setProperties() 方法就是一个单纯的 setter 方法, 主要是为了方便通过配置文件配置 Interceptor 的一些属性值, 没有其他作用. Interceptor 类中 intecept() 和 plugin() 函数, 以及 InterceptorChain 类中的 pluginAll() 函数, 是最核心的三个函数, 待会再详细解释.
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable;
Object plugin(Object target);
void setProperties(Properties properties);
}
2
3
4
5
public class InterceptorChain {
private final List<Interceptor> interceptors = new ArrayList<Interceptor>();
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
}
return target;
}
public void addInterceptor(Interceptor interceptor) {
interceptors.add(interceptor);
}
public List<Interceptor> getInterceptors() {
return Collections.unmodifiableList(interceptors);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
解析完配置文件之后, 所有的 Interceptor 都加载到了 InterceptorChain 中. 接下来再来看下, 这些拦截器是在什么时候被触发执行的? 又是如何被触发执行的呢?
前面提到, 在执行 SQL 的过程中, MyBatis 会创建 Executor, StatementHandler, ParameterHandler, ResultSetHandler 这几个类的对象, 对应的创建代码在 Configuration 类中, 如下所示:
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType <mark> null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH </mark> executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
return parameterHandler;
}
public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
ResultHandler resultHandler, BoundSql boundSql) {
ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
return resultSetHandler;
}
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
从上面的代码中, 可以发现, 这几个类对象的创建过程都调用了 InteceptorChain 的 pluginAll() 方法. 这个方法的代码前面已经给出了. 可以回过头去再看一眼. 它的代码实现很简单, 嵌套调用 InterceptorChain 上每个 Interceptor 的 plugin() 方法. plugin() 是一个接口方法(不包含实现代码), 需要由用户给出具体的实现代码. 在之前的例子中, SQLTimeCostInterceptor 的 plugin() 方法通过直接调用 Plugin 的 wrap() 方法来实现. wrap() 方法的代码实现如下所示:
// 借助Java InvocationHandler实现的动态代理模式
public class Plugin implements InvocationHandler {
private final Object target;
private final Interceptor interceptor;
private final Map<Class<?>, Set<Method>> signatureMap;
private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
this.target = target;
this.interceptor = interceptor;
this.signatureMap = signatureMap;
}
// wrap()静态方法, 用来生成target的动态代理,
// 动态代理对象=target对象+interceptor对象.
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
}
return target;
}
// 调用target上的f()方法, 会触发执行下面这个方法.
// 这个方法包含: 执行interceptor的intecept()方法 + 执行target上f()方法.
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
if (methods != null && methods.contains(method)) {
return interceptor.intercept(new Invocation(target, method, args));
}
return method.invoke(target, args);
} catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
}
}
private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
// issue #251
if# (interceptsAnnotation == null) {
throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());
}
Signature[] sigs = interceptsAnnotation.value();
Map<Class<?>, Set<Method>> signatureMap = new HashMap<Class<?>, Set<Method>>();
for (Signature sig : sigs) {
Set<Method> methods = signatureMap.get(sig.type());
if (methods == null) {
methods = new HashSet<Method>();
signatureMap.put(sig.type(), methods);
}
try {
Method method = sig.type().getMethod(sig.method(), sig.args());
methods.add(method);
} catch (NoSuchMethodException e) {
throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + e, e);
}
}
return signatureMap;
}
private static Class<?>[] getAllInterfaces(Class<?> type, Map<Class<?>, Set<Method>> signatureMap) {
Set<Class<?>> interfaces = new HashSet<Class<?>>();
while (type != null) {
for (Class<?> c : type.getInterfaces()) {
if (signatureMap.containsKey(c)) {
interfaces.add(c);
}
}
type = type.getSuperclass();
}
return interfaces.toArray(new Class<?>[interfaces.size()]);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
实际上, Plugin 是借助 Java InvocationHandler 实现的动态代理类. 用来代理给 target 对象添加 Interceptor 功能. 其中, 要代理的 target 对象就是 Executor, StatementHandler, ParameterHandler, ResultSetHandler 这四个类的对象. wrap() 静态方法是一个工具函数, 用来生成 target 对象的动态代理对象.
当然, 只有 interceptor 与 target 互相匹配的时候, wrap() 方法才会返回代理对象, 否则就返回 target 对象本身. 怎么才算是匹配呢? 那就是 interceptor 通过 @Signature 注解要拦截的类包含 target 对象, 具体可以参看 wrap() 函数的代码实现(上面一段代码中的第 16~19 行).
MyBatis 中的职责链模式的实现方式比较特殊. 它对同一个目标对象嵌套多次代理(也就是 InteceptorChain 中的 pluginAll() 函数要执行的任务). 每个代理对象(Plugin 对象)代理一个拦截器(Interceptor 对象)功能. 为了方便查看, 将 pluginAll() 函数的代码又拷贝到了下面.
public Object pluginAll(Object target) {
// 嵌套代理
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
// 上面这行代码等于下面这行代码, target(代理对象)=target(目标对象)+interceptor(拦截器功能)
// target = Plugin.wrap(target, interceptor);
}
return target;
}
// MyBatis像下面这样创建target(Executor, StatementHandler, ParameterHandler, ResultSetHandler), 相当于多次嵌套代理
Object target = interceptorChain.pluginAll(target);
2
3
4
5
6
7
8
9
10
11
12
当执行 Executor, StatementHandler, ParameterHandler, ResultSetHandler 这四个类上的某个方法的时候, MyBatis 会嵌套执行每层代理对象(Plugin 对象)上的 invoke() 方法. 而 invoke() 方法会先执行代理对象中的 interceptor 的 intecept() 函数, 然后再执行被代理对象上的方法. 就这样, 一层一层地把代理对象上的 intercept() 函数执行完之后, MyBatis 才最终执行那 4 个原始类对象上的方法.
# 89-开源实战五(下):总结MyBatis框架中用到的10种设计模式
上节剖析了利用职责链模式和动态代理模式实现 MyBatis Plugin. 至此已经学习了三种职责链常用的应用场景: 过滤器(Servlet Filter), 拦截器(Spring Interceptor), 插件(MyBatis Plugin) .
今天再对 MyBatis 用到的设计模式做一个总结. 它用到的设计模式也不少, 就我所知的不下十几种. 有些前面已经讲到, 有些比较简单. 有了前面这么多讲的学习和训练, 我想你现在应该已经具备了一定的研究和分析能力, 能够自己做查缺补漏, 把提到的所有源码都搞清楚.
# 1.SqlSessionFactoryBuilder:为什么要用建造者模式来创建SqlSessionFactory?
前面通过一个查询用户的例子展示了用 MyBatis 进行数据库编程. 为了方便查看, 把相关的代码重新摘抄到这里.
public class MyBatisDemo {
public static void main(String[] args) throws IOException {
Reader reader = Resources.getResourceAsReader("mybatis.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(reader);
SqlSession session = sessionFactory.openSession();
UserMapper userMapper = session.getMapper(UserMapper.class);
UserDo userDo = userMapper.selectById(8);
//...
}
}
2
3
4
5
6
7
8
9
10
针对这段代码, 请思考一下下面这个问题.
之前讲到建造者模式的时候, 使用 Builder 类来创建对象, 一般都是先级联一组 setXXX() 方法来设置属性, 然后再调用 build() 方法最终创建对象. 但在上面这段代码中, 通过 SqlSessionFactoryBuilder 来创建 SqlSessionFactory 并不符合这个套路. 它既没有 setter 方法, 而且 build() 方法也并非无参, 需要传递参数. 除此之外, 从上面的代码来看, SqlSessionFactory 对象的创建过程也并不复杂. 那直接通过构造函数来创建 SqlSessionFactory 不就行了吗? 为什么还要借助建造者模式创建 SqlSessionFactory 呢?
要回答这个问题, 就要先看下 SqlSessionFactoryBuilder 类的源码. 我把源码摘抄到了这里, 如下所示:
public class SqlSessionFactoryBuilder {
public SqlSessionFactory build(Reader reader) {
return build(reader, null, null);
}
public SqlSessionFactory build(Reader reader, String environment) {
return build(reader, environment, null);
}
public SqlSessionFactory build(Reader reader, Properties properties) {
return build(reader, null, properties);
}
public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
try {
XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
return build(parser.parse());
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error building SqlSession.", e);
} finally {
ErrorContext.instance().reset();
try {
reader.close();
} catch (IOException e) {
// Intentionally ignore. Prefer previous error.
}
}
}
public SqlSessionFactory build(InputStream inputStream) {
return build(inputStream, null, null);
}
public SqlSessionFactory build(InputStream inputStream, String environment) {
return build(inputStream, environment, null);
}
public SqlSessionFactory build(InputStream inputStream, Properties properties) {
return build(inputStream, null, properties);
}
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
try {
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
return build(parser.parse());
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error building SqlSession.", e);
} finally {
ErrorContext.instance().reset();
try {
inputStream.close();
} catch (IOException e) {
// Intentionally ignore. Prefer previous error.
}
}
}
public SqlSessionFactory build(Configuration config) {
return new DefaultSqlSessionFactory(config);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
SqlSessionFactoryBuilder 类中有大量的 build() 重载函数. 为了方便查看, 以及待会儿跟 SqlSessionFactory 类的代码作对比, 这里把重载函数定义抽象出来, 贴到这里.
public class SqlSessionFactoryBuilder {
public SqlSessionFactory build(Reader reader);
public SqlSessionFactory build(Reader reader, String environment);
public SqlSessionFactory build(Reader reader, Properties properties);
public SqlSessionFactory build(Reader reader, String environment, Properties properties);
public SqlSessionFactory build(InputStream inputStream);
public SqlSessionFactory build(InputStream inputStream, String environment);
public SqlSessionFactory build(InputStream inputStream, Properties properties);
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties);
// 上面所有的方法最终都调用这个方法 public SqlSessionFactory build(Configuration config);
}
2
3
4
5
6
7
8
9
10
11
12
13
如果一个类包含很多成员变量, 而构建对象并不需要设置所有的成员变量, 只需要选择性地设置其中几个就可以. 为了满足这样的构建需求, 就要定义多个包含不同参数列表的构造函数. 为了避免构造函数过多, 参数列表过长, 一般通过无参构造函数加 setter 方法或者通过建造者模式来解决.
从建造者模式的设计初衷上来看, SqlSessionFactoryBuilder 虽然带有 Builder 后缀, 但不要被它的名字所迷惑, 它并不是标准的建造者模式. 一方面, 原始类 SqlSessionFactory 的构建只需要一个参数, 并不复杂. 另一方面, Builder 类 SqlSessionFactoryBuilder 仍然定义了 n 多包含不同参数列表的构造函数.
实际上, SqlSessionFactoryBuilder 设计的初衷只不过是为了简化开发. 因为构建 SqlSessionFactory 需要先构建 Configuration, 而构建 Configuration 是非常复杂的, 需要做很多工作, 比如配置的读取, 解析, 创建 n 多对象等. 为了将构建 SqlSessionFactory 的过程隐藏起来, 对程序员透明, MyBatis 就设计了 SqlSessionFactoryBuilder 类封装这些构建细节.
# 2.SqlSessionFactory:到底属于工厂模式还是建造器模式?
在刚刚那段 MyBatis 示例代码中, 通过 SqlSessionFactoryBuilder 创建了 SqlSessionFactory, 然后再通过 SqlSessionFactory 创建了 SqlSession. 刚刚讲了 SqlSessionFactoryBuilder, 现在再来看下 SqlSessionFactory.
从名字上, 你可能已经猜到, SqlSessionFactory 是一个工厂类, 用到的设计模式是工厂模式. 不过, 它跟 SqlSessionFactoryBuilder 类似, 名字有很大的迷惑性. 实际上, 它也并不是标准的工厂模式. 为什么这么说呢? 先来看下 SqlSessionFactory 类的源码.
public interface SqlSessionFactory {
SqlSession openSession();
SqlSession openSession(boolean autoCommit);
SqlSession openSession(Connection connection);
SqlSession openSession(TransactionIsolationLevel level);
SqlSession openSession(ExecutorType execType);
SqlSession openSession(ExecutorType execType, boolean autoCommit);
SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level);
SqlSession openSession(ExecutorType execType, Connection connection);
Configuration getConfiguration();
}
2
3
4
5
6
7
8
9
10
11
SqlSessionFactory 是一个接口, DefaultSqlSessionFactory 是它唯一的实现类. DefaultSqlSessionFactory 源码如下所示:
public class DefaultSqlSessionFactory implements SqlSessionFactory {
private final Configuration configuration;
public DefaultSqlSessionFactory(Configuration configuration) {
this.configuration = configuration;
}
@Override
public SqlSession openSession() {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}
@Override
public SqlSession openSession(boolean autoCommit) {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, autoCommit);
}
@Override
public SqlSession openSession(ExecutorType execType) {
return openSessionFromDataSource(execType, null, false);
}
@Override
public SqlSession openSession(TransactionIsolationLevel level) {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), level, false);
}
@Override
public SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level) {
return openSessionFromDataSource(execType, level, false);
}
@Override
public SqlSession openSession(ExecutorType execType, boolean autoCommit) {
return openSessionFromDataSource(execType, null, autoCommit);
}
@Override
public SqlSession openSession(Connection connection) {
return openSessionFromConnection(configuration.getDefaultExecutorType(), connection);
}
@Override
public SqlSession openSession(ExecutorType execType, Connection connection) {
return openSessionFromConnection(execType, connection);
}
@Override
public Configuration getConfiguration() {
return configuration;
}
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
Transaction tx = null;
try {
final Environment environment = configuration.getEnvironment();
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
closeTransaction(tx); // may have fetched a connection so lets call close()
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) {
try {
boolean autoCommit;
try {
autoCommit = connection.getAutoCommit();
} catch (SQLException e) {
// Failover to true, as most poor drivers
// or databases won't support transactions
autoCommit = true;
}<br />final Environment environment = configuration.getEnvironment();
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
final Transaction tx = transactionFactory.newTransaction(connection);
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
//...省略部分代码...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
从 SqlSessionFactory 和 DefaultSqlSessionFactory 的源码来看, 它的设计非常类似刚刚讲到的 SqlSessionFactoryBuilder, 通过重载多个 openSession() 函数, 支持通过组合 autoCommit, Executor, Transaction 等不同参数, 来创建 SqlSession 对象. 标准的工厂模式通过 type 来创建继承同一个父类的不同子类对象, 而这里只不过是通过传递进不同的参数, 来创建同一个类的对象. 所以它更像建造者模式.
虽然设计思路基本一致, 但一个叫 xxxBuilder(SqlSessionFactoryBuilder), 一个叫 xxxFactory(SqlSessionFactory). 而且, 叫 xxxBuilder 的也并非标准的建造者模式, 叫 xxxFactory 的也并非标准的工厂模式. 所以我个人觉得, MyBatis 对这部分代码的设计还是值得优化的.
实际上, 这两个类的作用只不过是为了创建 SqlSession 对象, 没有其他作用. 所以更建议参照 Spring 的设计思路, 把 SqlSessionFactoryBuilder 和 SqlSessionFactory 的逻辑, 放到一个叫 "ApplicationContext" 的类中. 让这个类来全权负责读入配置文件, 创建 Congfiguration, 生成 SqlSession.
# 3.BaseExecutor:模板模式跟普通的继承有什么区别?
如果去查阅 SqlSession 与 DefaultSqlSession 的源码, 你会发现, SqlSession 执行 SQL 的业务逻辑, 都是委托给了 Executor 来实现. Executor 相关的类主要是用来执行 SQL. 其中, Executor 本身是一个接口; BaseExecutor 是一个抽象类, 实现了 Executor 接口; 而 BatchExecutor, SimpleExecutor, ReuseExecutor 三个类继承 BaseExecutor 抽象类.
那 BatchExecutor, SimpleExecutor, ReuseExecutor 三个类跟 BaseExecutor 是简单的继承关系, 还是模板模式关系呢? 怎么来判断呢? 看一下 BaseExecutor 的源码就清楚了.
public abstract class BaseExecutor implements Executor {
//...省略其他无关代码...
@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
clearLocalCache();
return doUpdate(ms, parameter);
}
public List<BatchResult> flushStatements(boolean isRollBack) throws SQLException {
if (closed) {
throw new ExecutorException("Executor was closed.");
}
return doFlushStatements(isRollBack);
}
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
@Override
public <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
return doQueryCursor(ms, parameter, rowBounds, boundSql);
}
protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException;
protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException;
protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException;
protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
模板模式基于继承来实现代码复用. 如果抽象类中包含模板方法, 模板方法调用有待子类实现的抽象方法, 那这一般就是模板模式的代码实现. 而且, 在命名上, 模板方法与抽象方法一般是一一对应的, 抽象方法在模板方法前面多一个 "do", 比如, 在 BaseExecutor 类中, 其中一个模板方法叫 update(), 那对应的抽象方法就叫 doUpdate().
# 4.SqlNode:如何利用解释器模式来解析动态SQL?
支持配置文件中编写动态 SQL, 是 MyBatis 一个非常强大的功能. 所谓动态 SQL, 就是在 SQL 中可以包含在 trim, if, #{} 等语法标签, 在运行时根据条件来生成不同的 SQL. 这么说比较抽象, 举个例子解释一下.
<update id="update" parameterType="com.xzg.cd.a89.User"
UPDATE user
<trim prefix="SET" prefixOverrides=",">
<if test="name != null and name != ''">
name = #{name}#
</if>
<if test="age != null and age != ''">
, age = #{age}#
</if>
<if test="birthday != null and birthday != ''">
, birthday = #{birthday}#
</if>
</trim>
where id = ${id}
</update>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
显然, 动态 SQL 的语法规则是 MyBatis 自定义的. 如果想要根据语法规则, 替换掉动态 SQL 中的动态元素, 生成真正可以执行的 SQL 语句, MyBatis 还需要实现对应的解释器. 这一部分功能就可以看做是解释器模式的应用. 实际上, 如果你去查看它的代码实现, 你会发现, 它跟在前面讲解释器模式时举的那两个例子的代码结构非常相似.
前面提到, 解释器模式在解释语法规则的时候, 一般会把规则分割成小的单元, 特别是可以嵌套的小单元, 针对每个小单元来解析, 最终再把解析结果合并在一起. 这里也不例外. MyBatis 把每个语法小单元叫 SqlNode. SqlNode 的定义如下所示:
public interface SqlNode {
boolean apply(DynamicContext context);
}
2
3
对于不同的语法小单元, MyBatis 定义不同的 SqlNode 实现类.

整个解释器的调用入口在 DynamicSqlSource.getBoundSql 方法中, 它调用了 rootSqlNode.apply(context) 方法.
# 5.ErrorContext:如何实现一个线程唯一的单例模式?
在单例模式那一部分讲到, 单例模式是进程唯一的. 同时还讲到单例模式的几种变形, 比如线程唯一的单例, 集群唯一的单例等. 在 MyBatis 中, ErrorContext 这个类就是标准单例的变形: 线程唯一的单例.
它的代码实现贴到下面了. 它基于 Java 中的 ThreadLocal 来实现.
public class ErrorContext {
private static final String LINE_SEPARATOR = System.getProperty("line.separator","\n");
private static final ThreadLocal<ErrorContext> LOCAL = new ThreadLocal<ErrorContext>();
private ErrorContext stored;
private String resource;
private String activity;
private String object;
private String message;
private String sql;
private Throwable cause;
private ErrorContext() {
}
public static ErrorContext instance() {
ErrorContext context = LOCAL.get();
if (context == null) {
context = new ErrorContext();
LOCAL.set(context);
}
return context;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 6.Cache:为什么要用装饰器模式而不设计成继承子类?
MyBatis 不只是简单地完成了对象和数据库数据之间的互相转化, 还提供了很多其他功能, 比如缓存, 事务等. 接下来再讲讲它的缓存实现.
在 MyBatis 中, 缓存功能由接口 Cache 定义. PerpetualCache 类是最基础的缓存类, 是一个大小无限的缓存. 除此之外, MyBatis 还设计了 9 个包裹 PerpetualCache 类的装饰器类, 用来实现功能增强. 它们分别是: FifoCache, LoggingCache, LruCache, ScheduledCache, SerializedCache, SoftCache, SynchronizedCache, WeakCache, TransactionalCache.
public interface Cache {
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
ReadWriteLock getReadWriteLock();
}
2
3
4
5
6
7
8
9
public class PerpetualCache implements Cache {
private final String id;
private Map<Object, Object> cache = new HashMap<Object, Object>();
public PerpetualCache(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public int getSize() {
return cache.size();
}
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
@Override
public Object getObject(Object key) {
return cache.get(key);
}
@Override
public Object removeObject(Object key) {
return cache.remove(key);
}
@Override
public void clear() {
cache.clear();
}
@Override
public ReadWriteLock getReadWriteLock() {
return null;
}
// 省略部分代码...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这 9 个装饰器类的代码结构都类似, 只将其中的 LruCache 的源码贴到这里. 从代码中可以看出, 它是标准的装饰器模式的代码实现.
public class LruCache implements Cache {
private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;
public LruCache(Cache delegate) {
this.delegate = delegate;
setSize(1024);
}
@Override
public String getId() {
return delegate.getId();
}
@Override
public int getSize() {
return delegate.getSize();
}
public void setSize(final int size) {
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
cycleKeyList(key);
}
@Override
public Object getObject(Object key) {
keyMap.get(key); //touch
return delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
@Override
public void clear() {
delegate.clear();
keyMap.clear();
}
@Override
public ReadWriteLock getReadWriteLock() {
return null;
}
private void cycleKeyList(Object key) {
keyMap.put(key, key);
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
之所以 MyBatis 采用装饰器模式来实现缓存功能, 是因为装饰器模式采用了组合, 而非继承, 更加灵活, 能够有效地避免继承关系的组合爆炸.
# 7.PropertyTokenizer:如何利用迭代器模式实现一个属性解析器?
迭代器模式常用来替代 for 循环遍历集合元素. Mybatis 的 PropertyTokenizer 类实现了 Java Iterator 接口, 是一个迭代器, 用来对配置属性进行解析. 具体的代码如下所示:
// person[0].birthdate.year 会被分解为3个PropertyTokenizer对象. 其中, 第一个PropertyTokenizer对象的各个属性值如注释所示.
public class PropertyTokenizer implements Iterator<PropertyTokenizer> {
private String name; // person
private final String indexedName; // person[0]
private String index; // 0
private final String children; // birthdate.year
public PropertyTokenizer(String fullname) {
int delim = fullname.indexOf('.');
if (delim > -1) {
name = fullname.substring(0, delim);
children = fullname.substring(delim + 1);
} else {
name = fullname;
children = null;
}
indexedName = name;
delim = name.indexOf('[');
if (delim > -1) {
index = name.substring(delim + 1, name.length() - 1);
name = name.substring(0, delim);
}
}
public String getName() {
return name;
}
public String getIndex() {
return index;
}
public String getIndexedName() {
return indexedName;
}
public String getChildren() {
return children;
}
@Override
public boolean hasNext() {
return children != null;
}
@Override
public PropertyTokenizer next() {
return new PropertyTokenizer(children);
}
@Override
public void remove() {
throw new UnsupportedOperationException("Remove is not supported, as it has no meaning in the context of properties.");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
实际上, PropertyTokenizer 类也并非标准的迭代器类. 它将配置的解析, 解析之后的元素, 迭代器, 这三部分本该放到三个类中的代码, 都耦合在一个类中, 所以看起来稍微有点难懂. 不过, 这样做的好处是能够做到惰性解析. 不需要事先将整个配置, 解析成多个 PropertyTokenizer 对象. 只有当在调用 next() 函数的时候, 才会解析其中部分配置.
# 8.Log:如何使用适配器模式来适配不同的日志框架?
Slf4j 框架为了统一各个不同的日志框架(Log4j, JCL, Logback 等), 提供了一套统一的日志接口. 不过, MyBatis 并没有直接使用 Slf4j 提供的统一日志规范, 而是自己又重复造轮子, 定义了一套自己的日志访问接口.
public interface Log {
boolean isDebugEnabled();
boolean isTraceEnabled();
void error(String s, Throwable e);
void error(String s);
void debug(String s);
void trace(String s);
void warn(String s);
}
2
3
4
5
6
7
8
9
针对 Log 接口, MyBatis 还提供了各种不同的实现类, 分别使用不同的日志框架来实现 Log 接口.

这几个实现类的代码结构基本上一致. 把其中的 Log4jImpl 的源码贴到了这里. 在适配器模式中, 传递给适配器构造函数的是被适配的类对象, 而这里是 clazz(相当于日志名称 name), 所以从代码实现上来讲, 它并非标准的适配器模式. 但从应用场景上来看, 这里确实又起到了适配的作用, 是典型的适配器模式的应用场景.
import org.apache.ibatis.logging.Log;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;
public class Log4jImpl implements Log {
private static final String FQCN = Log4jImpl.class.getName();
private final Logger log;
public Log4jImpl(String clazz) {
log = Logger.getLogger(clazz);
}
@Override
public boolean isDebugEnabled() {
return log.isDebugEnabled();
}
@Override
public boolean isTraceEnabled() {
return log.isTraceEnabled();
}
@Override
public void error(String s, Throwable e) {
log.log(FQCN, Level.ERROR, s, e);
}
@Override
public void error(String s) {
log.log(FQCN, Level.ERROR, s, null);
}
@Override
public void debug(String s) {
log.log(FQCN, Level.DEBUG, s, null);
}
@Override
public void trace(String s) {
log.log(FQCN, Level.TRACE, s, null);
}
@Override
public void warn(String s) {
log.log(FQCN, Level.WARN, s, null);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 项目实战
从今天开始正式进入项目实战模块. 如果说前面讲开源实战是学习别人怎么做, 那现在我们讲项目实战就是带你一块做. 在这个过程中, 我会带你实践之前学过的设计思想, 原则和模式, 给你展示怎么应用这些理论知识, 让你开发出跟前面那些著名开源项目一样优秀的软件.
在项目实战中, 找了三个稍微有点难度的项目: 限流框架, 幂等框架, 灰度发布组件. 针对每一个项目, 都会从分析, 设计, 实现这三个部分来讲解. 当然, 还是那句老话, 项目本身的讲解不是重点, 重点还是学习它们背后的开发套路. 这才是最有价值的部分.
# 90-项目实战一:设计实现一个支持各种算法的限流框架(分析)
接下来的三节讲第一个实战项目, 限流框架. 今天先讲其中的分析环节, 介绍项目背景, 分析项目需求.
# 1.项目背景
先来讲下需求诞生的背景. 这个背景跟下一个实战项目幂等框架也有关系, 所以要从很久很久讲起, 希望你能耐心看完, 不然后面可能会看不懂.
公司成立初期, 团队人少. 公司集中精力开发一个金融理财产品(把这个项目叫做 X 项目). 整个项目只做了简单的前后端分离, 后端的所有代码都在一个 GitHub 仓库中, 整个后端作为一个应用来部署, 没有划分微服务.
遇到了行业风口, 公司发展得不错, 公司开始招更多人, 开发更多的金融产品, 比如专注房贷的理财产品, 专注供应链的产品, 专注消费贷的借款端产品等等. 在产品形态上, 每个金融产品都做成了独立的 App. 对于不同的金融产品, 尽管移动端长得不一样, 但是后端的很多功能, 代码都是可以复用的. 为了快速上线, 针对每个应用, 公司都成立一个新的团队, 然后拷贝 X 项目的代码, 在此基础之上修改, 添加新的功能.
这样成立新团队, 拷贝老代码, 改改就能上线一个新产品的开发模式, 在一开始很受欢迎. 产品上线快, 也给公司赢得了竞争上的优势. 但时间一长, 这样的开发模式暴露出来的问题就越来越多了. 而且随着公司的发展, 公司也过了急速扩张期, 人招得太多, 公司开始考虑研发效率问题了.
因为所有的项目的代码都是从 X 项目拷贝来的, 多个团队同时维护相似的代码, 显然是重复劳动, 协作起来也非常麻烦. 任何团队发现代码的 bug, 都要同步到其他团队做相同的修改. 而且各个团队对代码独立迭代, 改得面目全非, 即便要添加一个通用的功能, 每个团队也都要基于自己的代码再重复开发.
除此之外, 公司成立初期, 各个方面条件有限, 只能招到开发水平一般的员工, 而且追求快速上线, 所以, X 项目的代码质量很差, 结构混乱, 命名不规范, 到处是临时解决方案, 埋了很多坑, 在烂代码之上不停地堆砌烂代码, 时间长了, 代码的可读性越来越差, 维护成本越来越高, 甚至高过了重新开发的成本.
这个时候该怎么办呢? 如果让你出出主意, 你有什么好的建议吗?
可以把公共的功能, 代码抽离出来, 形成一个独立的项目, 部署成一个公共服务平台. 所有金融产品的后端还是参照 MVC 三层架构独立开发, 不过它们只实现自己特有的功能, 对于一些公共的功能, 通过远程调用公共服务平台提供的接口来实现.
这里提到的公共服务平台, 有点类似现在比较火的"中台"或"微服务". 不过, 为了减少部署, 维护多个微服务的成本, 我们把所有公共的功能, 放到一个项目中开发, 放到一个应用中部署. 只不过, 要未雨绸缪, 事先按照领域模型, 将代码的模块化做好, 等到真的有哪个模块的接口调用过于集中, 性能出现瓶颈的时候, 再把它拆分出来, 设计成独立的微服务来开发和部署.
经过这样的拆分之后, 可以指派一个团队, 集中维护公共服务平台的代码. 开发一个新的金融产品, 也只需要更少的人员来参与, 因为他们只需要开发, 维护产品特有的功能和代码就可以了. 整体上, 维护成本降低了. 除此之外, 公共服务平台的代码集中到了一个团队手里, 重构起来不需要协调其他团队和项目, 也便于重构, 改善代码质量.
# 2.需求背景
对于公共服务平台来说, 接口请求来自很多不同的系统(后面统称为调用方), 比如各种金融产品的后端系统. 在系统上线一段时间里, 遇到了很多问题. 比如, 因为调用方代码 bug , 不正确地使用服务(比如启动 Job 来调用接口获取数据), 业务上面的突发流量(比如促销活动), 导致来自某个调用方的接口请求数突增, 过度争用服务的线程资源, 而来自其他调用方的接口请求, 因此来不及响应而排队等待, 导致接口请求的响应时间大幅增加, 甚至出现超时.
为了解决这个问题, 你有什么好的建议呢? 先来说说我的.
可以开发接口限流功能, 限制每个调用方对接口请求的频率. 当超过预先设定的访问频率后, 就触发限流熔断, 比如, 限制调用方 app-1 对公共服务平台总的接口请求频率不超过 1000 次/秒, 超过之后的接口请求都会被决绝. 除此之外, 为了更加精细化地限流, 除了限制每个调用方对公共服务平台总的接口请求频率之外, 还希望能对单独某个接口的访问频率进行限制, 比如, 限制 app-1 对接口 /user/query 的访问频率为每秒钟不超过 100 次.
我们希望开发出来的东西有一定的影响力, 即便做不到在行业内有影响力, 起码也要做到在公司范围内有影响力. 所以, 从一开始就不想把这个限流功能, 做成只有自己项目可用. 我们希望把它开发成一个通用的框架, 能够应用到各个业务系统中, 甚至可以集成到微服务治理平台中. 实际上, 这也体现了业务开发中要具备的抽象意识, 框架意识. 要善于识别出通用的功能模块, 将它抽象成通用的框架, 组件, 类库等.
# 3.需求分析
刚刚花了很大篇幅来介绍项目背景和需求背景, 接下来再对需求进行更加详细的分析和整理.
前面已经讲过一些需求分析的方法, 比如画线框图, 写用户用例, 测试驱动开发等等. 这里借助用户用例和测试驱动开发的思想, 先去思考, 如果框架最终被开发出来之后, 它会如何被使用. 我一般会找一个框架的应用场景, 针对这个场景写一个框架使用的 Demo 程序, 这样能够很直观地看到框架长什么样子. 知道了框架应该长什么样, 就相当于应试教育中确定了考试题目. 针对明确的考题去想解决方案, 这是我们多年应试教育锻炼之后最擅长做的.
对于限流框架来说, 来看下它的应用场景.
首先需要设置限流规则. 为了做到在不修改代码的前提下修改规则, 一般会把规则放到配置文件中(比如 XML, YAML 配置文件). 在集成了限流框架的应用启动的时候, 限流框架会将限流规则, 按照事先定义的语法, 解析并加载到内存中. 下面写了一个限流规则的 Demo 配置, 如下所示:
configs:
- appId: app-1
limits:
- api: /v1/user
limit: 100
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50
2
3
4
5
6
7
8
9
10
11
12
13
在接收到接口请求之后, 应用会将请求发送给限流框架, 限流框架会告诉应用, 这个接口请求是允许继续处理, 还是触发限流熔断. 如果用代码来将这个过程表示出来的话, 就是下面这个 Demo 的样子. 如果项目使用的是 Spring 框架, 可以利用 Spring AOP, 把这段限流代码放在统一的切面中, 在切面中拦截接口请求, 解析出请求对应的调用方 APP ID 和 URL, 然后验证是否对此调用方的这个接口请求进行限流.
String appId = "app-1"; // 调用方APP-ID
String url = "http://www.eudemon.com/v1/user/12345";// 请求url
RateLimiter ratelimiter = new RateLimiter();
boolean passed = ratelimiter.limit(appId, url);
if (passed) {
// 放行接口请求, 继续后续的处理.
} else {
// 接口请求被限流.
}
2
3
4
5
6
7
8
9
结合刚刚的 Demo, 从使用的角度来说, 限流框架主要包含两部分功能: 配置限流规则和提供编程接口(RateLimiter 类)验证请求是否被限流. 不过, 作为通用的框架, 除了功能性需求之外, 非功能性需求也非常重要, 有时候会决定一个框架的成败, 比如, 框架的易用性, 扩展性, 灵活性, 性能, 容错性等.
对于限流框架, 来看它都有哪些非功能性需求.
易用性方面, 希望限流规则的配置, 编程接口的使用都很简单. 希望提供各种不同的限流算法, 比如基于内存的单机限流算法, 基于 Redis 的分布式限流算法, 能够让使用者自由选择. 除此之外, 因为大部分项目都是基于 Spring 开发的, 还希望限流框架能否非常方便地集成到使用 Spring 框架的项目中.
扩展性, 灵活性方面, 希望能够灵活地扩展各种限流算法. 同时还希望支持不同格式(JSON, YAML, XML 等格式), 不同数据源(本地文件配置或 Zookeeper 集中配置等)的限流规则的配置方式.
性能方面, 因为每个接口请求都要被检查是否限流, 这或多或少会增加接口请求的响应时间. 而对于响应时间比较敏感的接口服务来说, 要让限流框架尽可能低延迟, 尽可能减少对接口请求本身响应时间的影响.
容错性方面, 接入限流框架是为了提高系统的可用性, 稳定性, 不能因为限流框架的异常, 反过来影响到服务本身的可用性. 所以限流框架要有高度的容错性. 比如, 分布式限流算法依赖集中存储器 Redis. 如果 Redis 挂掉了, 限流逻辑无法正常运行, 这个时候业务接口也要能正常服务才行.
# 91-项目实战一:设计实现一个支持各种算法的限流框架(设计)
上一节介绍了限流框架产生的项目背景, 并且对需求做了分析, 这其中包括功能性需求和非功能性需求, 算是在正式开始设计之前的一个铺垫.
前面提到, 把项目实战分为分析, 设计, 实现三部分来讲解. 其中, 分析环节跟之前讲过的面向对象分析很相似, 都是做需求的梳理. 但项目实战中的设计和实现, 跟面向对象设计和实现就不是一回事儿了. 这里的 "设计" 指的是系统设计, 主要是划分模块, 对模块进行设计. 这里的 "实现" 实际上等于面向对象设计加实现. 因为前面讲到, 面向对象设计与实现是聚焦在代码层面的, 主要产出的是类的设计和实现.
今天分限流规则, 限流算法, 限流模式, 集成使用这 4 个模块, 来讲解限流框架的设计思路. 上节提到, 限流框架的基本功能非常简单, 复杂在于它的非功能性需求, 所以今天讲解的重点是, 看如何通过合理的设计, 实现一个满足易用, 易扩展, 灵活, 低延时, 高容错等非功能性需求的限流框架.
# 1.限流规则
框架需要定义限流规则的语法格式, 包括调用方, 接口, 限流阈值, 时间粒度这几个元素. 框架用户按照这个语法格式来配置限流规则. 举一个例子来说明一下, 如下所示. 其中, unit 表示限流时间粒度, 默认情况下是 1 秒. limit 表示在 unit 时间粒度内最大允许的请求次数. 拿第一条规则来举例, 它表示的意思就是: 调用方 app-1 对接口 /v1/user 每分钟的最大请求次数不能超过 100 次.
configs:
- appId: app-1
limits:
- api: /v1/user
limit: 100
unit: 60
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50
2
3
4
5
6
7
8
9
10
11
12
13
14
对于限流时间粒度的选择, 既可以选择限制 1 秒钟内不超过 1000 次, 也可以选择限制 10 毫秒内不超过 10 次, 还可以选择限制 1 分钟内不超过 6 万次. 虽然看起来这几种限流规则是等价的, 但过大的时间粒度会达不到限流的效果. 比如, 有可能 6 万次请求集中在 1 秒中到达, 限制 1 分钟不超过 6 万次, 就起不到保护的作用; 相反, 因为接口访问在细时间粒度上随机性很大, 并不会很均匀. 过小的时间粒度, 会误杀很多本不应该限流的请求. 所以, 尽管越细的时间粒度限流整形效果越好, 流量曲线越平滑, 但也并不是时间粒度越小越合适.
Spring 框架支持各种格式的配置文件, 比如 XML, YAML, Porperties 等. 除此之外, 基于约定优于配置原则, Spring 框架用户只需要将配置文件按照约定来命名, 并且放置到约定的路径下, Spring 框架就能按照约定自动查找和加载配置文件.
大部分 Java 程序员已经习惯了 Spring 的配置方式, 基于前面讲的最小惊奇原则, 在限流框架中, 也延续 Spring 的配置方式, 支持 XML, YAML, Properties 等几种配置文件格式, 同时约定默认的配置文件名为 ratelimiter-rule.yaml, 默认放置在 classpath 路径中.
除此之外, 为了提高框架的兼容性, 易用性, 除了刚刚讲的本地文件的配置方式之外, 还希望兼容从其他数据源获取配置的方式, 比如 Zookeeper 或者自研的配置中心.
# 2.限流算法
常见的限流算法有: 固定时间窗口限流算法, 滑动时间窗口限流算法, 令牌桶限流算法, 漏桶限流算法. 其中, 固定时间窗口限流算法最简单. 只需要选定一个起始时间起点, 之后每来一个接口请求, 都给计数器(记录当前时间窗口内的访问次数)加一, 如果在当前时间窗口内, 根据限流规则(比如每秒钟最大允许 100 次接口请求), 累加访问次数超过限流值(比如 100 次), 就触发限流熔断, 拒绝接口请求. 当进入下一个时间窗口之后, 计数器清零重新计数.
不过, 固定时间窗口的限流算法的缺点也很明显. 这种算法的限流策略过于粗略, 无法应对两个时间窗口临界时间内的突发流量. 举一个例子. 假设限流规则为每秒钟不超过 100 次接口请求. 第一个 1 秒时间窗口内, 100 次接口请求都集中在最后的 10 毫秒内, 在第二个 1 秒时间窗口内, 100 次接口请求都集中在最开始的 10 毫秒内. 虽然两个时间窗口内流量都符合限流要求 (小于等于 100 个接口请求), 但在两个时间窗口临界的 20 毫秒内集中有 200 次接口请求, 固定时间窗口限流算法没法对这种情况进行限流, 集中在这 20 毫秒内的 200 次请求有可能会压垮系统. 为了让流量更加平滑, 于是就有了更加高级的滑动时间窗口限流算法, 令牌桶限流算法和漏桶限流算法. 尽管固定时间窗口限流算法没法做到让流量很平滑, 但大部分情况下, 它已经够用了. 默认情况下, 框架使用固定时间窗口限流算法做限流. 不过, 考虑到框架的扩展性, 需要预先做好设计, 预留好扩展点, 方便今后扩展其他限流算法. 除此之外, 为了提高框架的易用性, 灵活性, 最好将其他几种常用的限流算法, 也在框架中实现出来, 供框架用户根据自己业务场景自由选择.
# 3.限流模式
刚刚讲的是限流算法, 再讲讲限流模式. 限流模式一般分为两种: 单机限流和分布式限流. 所谓单机限流, 就是针对单个实例的访问频率进行限制. 注意这里的单机并不是真的一台物理机器, 而是一个服务实例, 因为有可能一台物理机器部署多个实例. 所谓的分布式限流, 就是针对某个服务的多个实例的总的访问频率进行限制. 举个例子解释一下.
假设开发了一个用户相关的微服务, 为了提高服务能力, 部署了 5 个实例. 如果限制某个调用方, 对单个实例的某个接口的访问频率, 不能超过 100 次/秒. 这就是单机限流. 限制某个调用方, 对 5 个实例的某个接口的总访问频率, 不能超过 500 次/秒. 这就是所谓的分布式限流.
从实现的角度来分析, 单机限流和分布式限流的主要区别在接口访问计数器的实现. 单机限流只需要在单个实例中维护自己的接口请求计数器. 而分布式限流需要集中管理计数器(比如使用 Redis 存储接口访问计数), 这样才能做到多个实例对同一个计数器累加计数, 以便实现对多个实例总访问频率的限制.
前面讲到框架要高容错, 不能因为框架的异常, 影响到集成框架的应用的可用性和稳定性. 除此之外, 还讲到框架要低延迟. 限流逻辑的执行不能占用太长时间, 不能或者很少影响接口请求本身的响应时间. 因为分布式限流基于外部存储 Redis, 网络通信成本较高, 实际上, 高容错, 低延迟设计的主要场景就是基于 Redis 实现的分布式限流.
对于 Redis 的各种异常情况, 处理起来并不难, 捕获并封装为统一的异常, 向上抛出或者吞掉就可以了. 比较难处理的是 Redis 访问超时. Redis 访问超时会严重影响接口的响应时间, 甚至导致接口请求超时. 所以, 在访问 Redis 时, 需要设置合理的超时时间. 一旦超时, 就判定为限流失效, 继续执行接口请求. Redis 访问超时时间的设置既不能太大也不能太小, 太大可能会影响到接口的响应时间, 太小可能会导致太多的限流失效. 可以通过压测或者线上监控, 获取到 Redis 访问时间分布情况, 再结合接口可以容忍的限流延迟时间, 权衡设置一个较合理的 Redis 超时时间.
# 4.集成使用
前面剖析 Spring 框架的时候, 讲到低侵入松耦合设计思想. 限流框架也应该满足这个设计思想. 因为框架是需要集成到应用中使用的, 希望框架尽可能低侵入, 与业务代码松耦合, 替换, 删除起来也更容易些.
除此之外, 在剖析 MyBatis 框架的时候, 讲到 MyBatis 框架是为了简化数据库编程. 实际上, 为了进一步简化开发, MyBatis 还提供了 MyBatis-Spring 类库, 方便在使用了 Spring 框架的项目中集成 MyBatis 框架. 也可以借鉴 MyBatis-Spring, 开发一个 Ratelimiter-Spring 类库, 能够方便使用了 Spring 的项目集成限流框架, 将易用性做到极致.
# 92-项目实战一:设计实现一个支持各种算法的限流框架(实现)
上一节介绍了如何通过合理的设计, 来实现功能性需求的同时, 满足易用, 易扩展, 灵活, 低延迟, 高容错等非功能性需求. 在设计的过程中, 也借鉴了之前讲过的一些开源项目的设计思想. 比如借鉴了 Spring 的低侵入松耦合, 约定优于配置等设计思想, 还借鉴了 MyBatis 通过 MyBatis-Spring 类库将框架的易用性做到极致等设计思路.
今天讲解这样一个问题, 针对限流框架的开发, 如何做高质量的代码实现. 说的具体点就是, 如何利用之前讲过的设计思想, 原则, 模式, 编码规范, 重构技巧等, 写出易读, 易扩展, 易维护, 灵活, 简洁, 可复用, 易测试的代码.
# 1.V1版本功能需求
前面提到, 优秀的代码是重构出来的, 复杂的代码是慢慢堆砌出来的. 小步快跑, 逐步迭代是我比较推崇的开发模式. 所以, 针对限流框架, 也不用想一下子就做得大而全. 况且在专栏有限的篇幅内, 我也不可能将一个大而全的代码阐述清楚. 所以可以先实现一个包含核心功能, 基本功能的 V1 版本.
针对上两节课中给出的需求和设计, 重新梳理一下, 看看有哪些功能要放到 V1 版本中实现.
在 V1 版本中, 对于接口类型, 只支持 HTTP 接口(也就 URL)的限流, 暂时不支持 RPC 等其他类型的接口限流. 对于限流规则, 只支持本地文件配置, 配置文件格式只支持 YAML. 对于限流算法, 只支持固定时间窗口算法. 对于限流模式, 只支持单机限流.
尽管功能"裁剪"之后, V1 版本实现起来简单多了, 但在编程开发的同时, 还是要考虑代码的扩展性, 预留好扩展点. 这样, 在接下来的新版本开发中, 才能够轻松地扩展新的限流算法, 限流模式, 限流规则格式和数据源.
# 2.最小原型代码
上节讲到, 项目实战中的实现等于面向对象设计加实现. 而面向对象设计与实现一般可以分为四个步骤: 划分职责识别类, 定义属性和方法, 定义类之间的交互关系, 组装类并提供执行入口.
不过前面也讲到, 在平时的工作中, 大部分程序员都是边写代码边做设计, 边思考边重构, 并不会严格地按照步骤, 先做完类的设计再去写代码. 而且, 如果想一下子就把类设计得很好, 很合理, 也是比较难的. 过度追求完美主义, 只会导致迟迟下不了手, 连第一行代码也敲不出来. 所以, 我的习惯是, 先完全不考虑设计和代码质量, 先把功能完成, 先把基本的流程走通, 哪怕所有的代码都写在一个类中也无所谓. 然后再针对这个 MVP 代码(最小原型代码)做优化重构, 比如, 将代码中比较独立的代码块抽离出来, 定义成独立的类或函数.
按照先写 MVP 代码的思路, 把代码实现出来. 它的目录结构如下所示. 代码非常简单, 只包含 5 个类, 接下来, 针对每个类一一讲解一下.
com.xzg.ratelimiter
--RateLimiter
com.xzg.ratelimiter.rule
--ApiLimit
--RuleConfig
--RateLimitRule
com.xzg.ratelimiter.alg
--RateLimitAlg
2
3
4
5
6
7
8
先来看下 RateLimiter 类. 代码如下所示:
public class RateLimiter {
private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
// 为每个api在内存中存储限流计数器
private ConcurrentHashMap<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
private RateLimitRule rule;
public RateLimiter() {
// 将限流规则配置文件ratelimiter-rule.yaml中的内容读取到RuleConfig中
InputStream in = null;
RuleConfig ruleConfig = null;
try {
in = this.getClass().getResourceAsStream("/ratelimiter-rule.yaml");
if (in != null) {
Yaml yaml = new Yaml();
ruleConfig = yaml.loadAs(in, RuleConfig.class);
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
}
// 将限流规则构建成支持快速查找的数据结构RateLimitRule
this.rule = new RateLimitRule(ruleConfig);
}
public boolean limit(String appId, String url) throws InternalErrorException {
ApiLimit apiLimit = rule.getLimit(appId, url);
if (apiLimit == null) {
return true;
}
// 获取api对应在内存中的限流计数器(rateLimitCounter)
String counterKey = appId + ":" + apiLimit.getApi();
RateLimitAlg rateLimitCounter = counters.get(counterKey);
if (rateLimitCounter == null) {
RateLimitAlg newRateLimitCounter = new RateLimitAlg(apiLimit.getLimit());
rateLimitCounter = counters.putIfAbsent(counterKey, newRateLimitCounter);
if (rateLimitCounter == null) {
rateLimitCounter = newRateLimitCounter;
}
}
// 判断是否限流
return rateLimitCounter.tryAcquire();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
RateLimiter 类用来串联整个限流流程. 它先读取限流规则配置文件, 映射为内存中的 Java 对象(RuleConfig), 然后再将这个中间结构构建成一个支持快速查询的数据结构(RateLimitRule). 除此之外, 这个类还提供供用户直接使用的最顶层接口(limit() 接口).
再来看下 RuleConfig 和 ApiLimit 两个类. 代码如下所示:
public class RuleConfig {
private List<UniformRuleConfig> configs;
public List<AppRuleConfig> getConfigs() {
return configs;
}
public void setConfigs(List<AppRuleConfig> configs) {
this.configs = configs;
}
public static class AppRuleConfig {
private String appId;
private List<ApiLimit> limits;
public AppRuleConfig() {}
public AppRuleConfig(String appId, List<ApiLimit> limits) {
this.appId = appId;
this.limits = limits;
}
//...省略getter, setter方法...
}
}
public class ApiLimit {
private static final int DEFAULT_TIME_UNIT = 1; // 1 second
private String api;
private int limit;
private int unit = DEFAULT_TIME_UNIT;
public ApiLimit() {}
public ApiLimit(String api, int limit) {
this(api, limit, DEFAULT_TIME_UNIT);
}
public ApiLimit(String api, int limit, int unit) {
this.api = api;
this.limit = limit;
this.unit = unit;
}
// ...省略getter, setter方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
从代码中可以看出来, RuleConfig 类嵌套了另外两个类 AppRuleConfig 和 ApiLimit. 这三个类跟配置文件的三层嵌套结构完全对应. 我把对应关系标注在了下面的示例中, 可以对照着代码看下.
configs: <!--对应RuleConfig-->
- appId: app-1 <!--对应AppRuleConfig-->
limits:
- api: /v1/user <!--对应ApiLimit-->
limit: 100
unit: 60
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50
2
3
4
5
6
7
8
9
10
11
12
13
14
再来看下 RateLimitRule 这个类.
你可能会好奇, 有了 RuleConfig 来存储限流规则, 为什么还要 RateLimitRule 类呢? 这是因为, 限流过程中会频繁地查询接口对应的限流规则, 为了尽可能地提高查询速度, 需要将限流规则组织成一种支持按照 URL 快速查询的数据结构. 考虑到 URL 的重复度比较高, 且需要按照前缀来匹配, 这里选择使用 Trie 树这种数据结构. 举个例子解释一下, 如下图所示. 左边的限流规则对应到 Trie 树, 就是图中右边的样子.

RateLimitRule 的实现代码比较多, 就不在这里贴出来了, 只给出它的定义, 如下所示.
public class RateLimitRule {
public RateLimitRule(RuleConfig ruleConfig) {
//...
}
public ApiLimit getLimit(String appId, String api) {
//...
}
}
2
3
4
5
6
7
8
9
最后, 看下 RateLimitAlg 这个类.
这个类是限流算法实现类. 它实现了最简单的固定时间窗口限流算法. 每个接口都要在内存中对应一个 RateLimitAlg 对象, 记录在当前时间窗口内已经被访问的次数. RateLimitAlg 类的代码如下所示.
public class RateLimitAlg {
/* timeout for {@code Lock.tryLock() }. */
private static final long TRY_LOCK_TIMEOUT = 200L; // 200ms.
private Stopwatch stopwatch;
private AtomicInteger currentCount = new AtomicInteger(0);
private final int limit;
private Lock lock = new ReentrantLock();
public RateLimitAlg(int limit) {
this(limit, Stopwatch.createStarted());
}
@VisibleForTesting
protected RateLimitAlg(int limit, Stopwatch stopwatch) {
this.limit = limit;
this.stopwatch = stopwatch;
}
public boolean tryAcquire() throws InternalErrorException {
int updatedCount = currentCount.incrementAndGet();
if (updatedCount <= limit) {
return true;
}
try {
if (lock.tryLock(TRY_LOCK_TIMEOUT, TimeUnit.MILLISECONDS)) {
try {
if (stopwatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {
currentCount.set(0);
stopwatch.reset();
}
updatedCount = currentCount.incrementAndGet();
return updatedCount <= limit;
} finally {
lock.unlock();
}
} else {
throw new InternalErrorException("tryAcquire() wait lock too long:" + TRY_LOCK_TIMEOUT + "ms");
}
} catch (InterruptedException e) {
throw new InternalErrorException("tryAcquire() is interrupted by lock-time-out.", e);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 3.Review最小原型代码
刚刚给出的 MVP 代码, 虽然总共也就 200 多行, 但已经实现了 V1 版本中规划的功能. 不过从代码质量的角度来看, 它还有很多值得优化的地方. 现在, 现在站在一个 Code Reviewer 的角度, 来分析一下这段代码的设计和实现.
结合 SOLID, DRY, KISS, LOD, 基于接口而非实现编程, 高内聚松耦合等经典的设计思想和原则, 以及编码规范, 从代码质量评判标准的角度重点剖析一下, 这段代码在可读性, 扩展性等方面的表现. 其他方面的表现, 比如复用性, 可测试性等, 这些你可以比葫芦画瓢, 自己来进行分析.
首先, 来看下代码的可读性.
影响代码可读性的因素有很多. 重点关注目录设计(package 包)是否合理, 模块划分是否清晰, 代码结构是否高内聚低耦合, 以及是否符合统一的编码规范这几点.
因为涉及的代码不多, 目录结构前面也给出了, 总体来说比较简单, 所以目录设计, 包的划分没有问题.
按照上节课中的模块划分, RuleConfig, ApiLimit, RateLimitRule 属于 "限流规则" 模块, 负责限流规则的构建和查询. RateLimitAlg 属于 "限流算法" 模块, 提供了基于内存的单机固定时间窗口限流算法. RateLimiter 类属于 "集成使用" 模块, 作为最顶层类, 组装其他类, 提供执行入口(也就是调用入口). 不过, RateLimiter 类作为执行入口, 我们希望它只负责组装工作, 而不应该包含具体的业务逻辑, 所以 RateLimiter 类中, 从配置文件中读取限流规则这块逻辑, 应该拆分出来设计成独立的类.
如果把类与类之间的依赖关系图画出来, 你会发现, 它们之间的依赖关系很简单, 每个类的职责也比较单一, 所以类的设计满足单一职责原则, LOD 迪米特法则, 高内聚松耦合的要求.
从编码规范上来讲, 没有超级大的类, 函数, 代码块. 类, 函数, 变量的命名基本能达意, 也符合最小惊奇原则. 虽然, 有些命名不能一眼就看出是干啥的, 有些命名采用了缩写, 比如 RateLimitAlg, 但是起码能猜个八九不离十, 结合注释(限于篇幅注释都没有写, 并不代表不需要写), 很容易理解和记忆.
总结一下, 在最小原型代码中, 目录设计, 代码结构, 模块划分, 类的设计还算合理清晰, 基本符合编码规范, 代码的可读性不错!
其次, 再来看下代码的扩展性.
实际上, 这段代码最大的问题就是它的扩展性, 也是我们最关注的, 毕竟后续还有更多版本的迭代开发. 编写可扩展代码, 关键是要建立扩展意识. 在写代码的时候, 要时刻思考, 这段代码如果要扩展新的功能, 那是否可以在尽量少改动代码的情况下完成, 还是需要要大动干戈, 推倒重写.
具体到 MVP 代码, 不易扩展的最大原因是, 没有遵循基于接口而非实现的编程思想, 没有接口抽象意识. 比如, RateLimitAlg 类只是实现了固定时间窗口限流算法, 也没有提炼出更加抽象的算法接口. 如果要替换其他限流算法, 就要改动比较多的代码. 其他类的设计也有同样的问题, 比如 RateLimitRule.
除此之外, 在 RateLimiter 类中, 配置文件的名称, 路径, 是硬编码在代码中的. 尽管说约定优于配置, 但也要兼顾灵活性, 能够让用户在需要的时候, 自定义配置文件名称, 路径. 而且, 配置文件的格式只支持 Yaml, 之后扩展其他格式, 需要对这部分代码做很大的改动.
# 4.重构最小原型代码
根据刚刚对 MVP 代码的剖析, 它的可读性没有太大问题, 问题主要在于可扩展性. 主要的修改点有两个, 一个是将 RateLimiter 中的规则配置文件的读取解析逻辑拆出来, 设计成独立的类, 另一个是参照基于接口而非实现编程思想, 对于 RateLimitRule, RateLimitAlg 类提炼抽象接口.
按照这个修改思路, 对代码进行重构. 重构之后的目录结构如下所示.
// 重构前:
com.xzg.ratelimiter
--RateLimiter
com.xzg.ratelimiter.rule
--ApiLimit
--RuleConfig
--RateLimitRule
com.xzg.ratelimiter.alg
--RateLimitAlg
// 重构后:
com.xzg.ratelimiter
--RateLimiter(有所修改)
com.xzg.ratelimiter.rule
--ApiLimit(不变)
--RuleConfig(不变)
--RateLimitRule(抽象接口)
--TrieRateLimitRule(实现类, 就是重构前的RateLimitRule)
com.xzg.ratelimiter.rule.parser
--RuleConfigParser(抽象接口)
--YamlRuleConfigParser(Yaml格式配置文件解析类)
--JsonRuleConfigParser(Json格式配置文件解析类)
com.xzg.ratelimiter.rule.datasource
--RuleConfigSource(抽象接口)
--FileRuleConfigSource(基于本地文件的配置类)
com.xzg.ratelimiter.alg
--RateLimitAlg(抽象接口)
--FixedTimeWinRateLimitAlg(实现类, 就是重构前的RateLimitAlg)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
其中, RateLimiter 类重构之后的代码如下所示. 代码的改动集中在构造函数中, 通过调用 RuleConfigSource 来实现了限流规则配置文件的加载.
public class RateLimiter {
private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
// 为每个api在内存中存储限流计数器
private ConcurrentHashMap<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
private RateLimitRule rule;
public RateLimiter() {
// 改动主要在这里: 调用RuleConfigSource类来实现配置加载! ! ! !
RuleConfigSource configSource = new FileRuleConfigSource();
RuleConfig ruleConfig = configSource.load();
this.rule = new TrieRateLimitRule(ruleConfig);
}
public boolean limit(String appId, String url) throws InternalErrorException, InvalidUrlException {
//...代码不变...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
再来看下, 从 RateLimiter 中拆分出来的限流规则加载的逻辑, 现在是如何设计的. 这部分涉及的类主要是下面几个. 我把关键代码也贴在了下面. 其中, 各个 Parser 和 RuleConfigSource 类的设计有点类似策略模式, 如果要添加新的格式的解析, 只需要实现对应的 Parser 类, 并且添加到 FileRuleConfig 类的 PARSER_MAP 中就可以了.
com.xzg.ratelimiter.rule.parser
--RuleConfigParser(抽象接口)
--YamlRuleConfigParser(Yaml格式配置文件解析类)
--JsonRuleConfigParser(Json格式配置文件解析类)
com.xzg.ratelimiter.rule.datasource
--RuleConfigSource(抽象接口)
--FileRuleConfigSource(基于本地文件的配置类)
2
3
4
5
6
7
public interface RuleConfigParser {
RuleConfig parse(String configText);
RuleConfig parse(InputStream in);
}
public interface RuleConfigSource {
RuleConfig load();
}
public class FileRuleConfigSource implements RuleConfigSource {
private static final Logger log = LoggerFactory.getLogger(FileRuleConfigSource.class);
public static final String API_LIMIT_CONFIG_NAME = "ratelimiter-rule";
public static final String YAML_EXTENSION = "yaml";
public static final String YML_EXTENSION = "yml";
public static final String JSON_EXTENSION = "json";
private static final String[] SUPPORT_EXTENSIONS =
new String[] {YAML_EXTENSION, YML_EXTENSION, JSON_EXTENSION};
private static final Map<String, RuleConfigParser> PARSER_MAP = new HashMap<>();
static {
PARSER_MAP.put(YAML_EXTENSION, new YamlRuleConfigParser());
PARSER_MAP.put(YML_EXTENSION, new YamlRuleConfigParser());
PARSER_MAP.put(JSON_EXTENSION, new JsonRuleConfigParser());
}
@Override
public RuleConfig load() {
for (String extension : SUPPORT_EXTENSIONS) {
InputStream in = null;
try {
in = this.getClass().getResourceAsStream("/" + getFileNameByExt(extension));
if (in != null) {
RuleConfigParser parser = PARSER_MAP.get(extension);
return parser.parse(in);
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
}
}
return null;
}
private String getFileNameByExt(String extension) {
return API_LIMIT_CONFIG_NAME + "." + extension;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 93-项目实战二:设计实现一个通用的接口幂等框架(分析)
上三节分析, 设计, 实现了一个接口限流框架. 在分析阶段, 讲到需求分析的两大方面, 功能性需求分析和非功能性需求分析. 在设计阶段, 讲了如何通过合理的设计, 在实功能性需求的前提下, 满足易用, 易扩展, 灵活, 高性能, 高容错等非功能性需求. 在实现阶段, 讲了如何利用设计思想, 原则, 模式, 编码规范等, 编写可读, 可扩展等高质量的代码实现.
从今天开始, 来实战一个新的项目, 开发一个通用的接口幂等框架. 跟限流框架一样, 还是分为分析, 设计, 实现三个部分, 对应三节来讲解.
# 1.需求场景
先来看下幂等框架的需求场景.
还记得之前讲到的限流框架的项目背景吗? 为了复用代码, 把通用的功能设计成了公共服务平台. 公司内部的其他金融产品的后台系统, 会调用公共服务平台的服务, 不需要完全从零开始开发. 公共服务平台提供的是 Restful 接口. 为了简化开发, 调用方一般使用 feign 框架(一个 HTTP 框架)来访问公共服务平台的接口.
调用方访问公共服务平台的接口, 会有三种可能的结果: 成功, 失败和超时. 前两种结果非常明确, 调用方可以自己决定收到结果之后如何处理. 结果为 "成功", 万事大吉. 结果为 "失败", 一般情况下, 调用方会将失败的结果, 反馈给用户(移动端 app), 让用户自行决定是否重试.
但是, 当接口请求超时时, 处理起来就没那么容易了. 有可能业务逻辑已经执行成功了, 只是公共服务平台返回结果给调用方的时候超时了, 但也有可能业务逻辑没有执行成功, 比如因为数据库当时存在集中写入, 导致部分数据写入超时. 总之, 超时对应的执行结果是未决的. 那调用方调用接口超时时(基于 feign 框架开发的话, 一般是收到 Timeout 异常), 该如何处理呢?
如果接口只包含查询, 删除, 更新这些操作, 那接口天然是幂等的. 所以, 超时之后, 重新再执行一次, 也没有任何副作用. 不过这里有两点需要特殊说明一下.
删除操作需要当心 ABA 问题. 删除操作超时了, 又触发一次删除, 但在这次删除之前, 又有一次新的插入. 后一次删除操作删除了新插入的数据, 而新插入的数据本不应该删除. 不过大部分业务都可以容忍 ABA 问题. 对于少数不能容忍的业务场景, 可以针对性的特殊处理.
除此之外, 细究起来, update x = x + delta 这样格式的更新操作并非幂等, 只有 update x = y 这样格式的更新操作才是幂等的. 不过, 后者也存在跟删除同样的 ABA 问题.
如果接口包含修改操作(插入操作, update x = x + delta 更新操作), 多次重复执行有可能会导致业务上的错误, 这是不能接受的. 如果插入的数据包含数据库唯一键, 可以利用数据库唯一键的排他性, 保证不会重复插入数据. 除此之外, 一般会建议调用方按照这样几种方式来处理.
第一种处理方式是, 调用方访问公共服务平台接口超时时, 返回清晰明确的提醒给用户, 告知执行结果未知, 让用户自己判断是否重试. 不过, 你可能会说, 如果用户看到了超时提醒, 但还是重新发起了操作, 比如重新发起了转账, 充值等操作, 那该怎么办呢? 实际上, 对这种情况, 技术是无能为力的. 因为两次操作都是用户主动发起的, 无法判断第二次的转账, 充值是新的操作, 还是基于上一次超时的重试行为.
第二种处理方式是, 调用方调用其他接口, 来查询超时操作的结果, 明确超时操作对应的业务, 是执行成功了还是失败了, 然后再基于明确的结果做处理. 但是这种处理方法存在一个问题, 那就是并不是所有的业务操作, 都方便查询操作结果.
第三种处理方式是, 调用方在遇到接口超时之后, 直接发起重试操作. 这样就需要接口支持幂等. 可以选择在业务代码中触发重试, 也可以将重试的操作放到 feign 框架中完成. 因为偶尔发生的超时, 在正常的业务逻辑中编写一大坨补救代码, 这样做会影响到代码的可读性, 有点划不来. 当然, 如果项目中需要支持超时重试的业务不多, 那对于仅有几个业务, 特殊处理一下也未尝不可. 但如果项目中需要支持超时重试的业务比较多, 最好是把超时重试这些非业务相关的逻辑, 统一在框架层面解决.
对响应时间敏感的调用方来说, 它们服务的是移动端的用户, 过长的等待时间, 还不如直接返回超时给用户. 所以, 这种情况下, 第一种处理方式是比较推荐的. 但对响应时间不敏感的调用方来说, 比如 Job 类的调用方, 推荐选择后两种处理方式, 能够提高处理的成功率. 而第二种处理方法, 本身有一定的局限性, 因为并不是所有业务操作都方便查询是否执行成功. 第三种保证接口幂等的处理方式, 是比较通用的解决方案. 所以针对这种处理方式, 抽象出一套统一的幂等框架, 简化幂等接口的开发.
# 2.需求分析
刚刚介绍了幂等框架的需求背景: 超时重试需要接口幂等的支持. 接下来再对需求进行更加详细的分析和整理, 这其中就包括功能性需求和非功能性需求.
不过, 在此之前需要先搞清楚一个重要的概念: 幂等号.
放到接口调用的这个场景里, 幂等的意思是, 针对同一个接口, 多次发起同一个业务请求, 必须保证业务只执行一次. 那如何判定两次接口请求是同一个业务请求呢? 也就是说, 如何判断两次接口请求是重试关系? 而非独立的两个业务请求? 比如, 两次调用转账接口, 尽管转账用户, 金额等参数都一样, 但也无法判断这两个转账请求就是重试关系.
实际上, 要确定重试关系, 就需要给同一业务请求一个唯一标识, 也就是 "幂等号"! 如果两个接口请求, 带有相同的幂等号, 那就判断它们是重试关系, 是同一个业务请求, 不要重复执行.
幂等号需要保证全局唯一性. 它可以有业务含义, 比如, 用户手机号码是唯一的, 对于用户注册接口来说, 可以拿它作为幂等号. 不过这样就会导致幂等框架的实现, 无法完全脱离具体的业务. 所以更加倾向于, 通过某种算法来随机生成没有业务含义的幂等号.
幂等号的概念搞清楚了, 再来看下框架的功能性需求.
前面也介绍了一些需求分析整理方法, 比如画线框图, 写用户用例, 基于测试驱动开发等. 跟限流框架类似, 这里也借助用户用例和测试驱动开发的思想, 先去思考, 如果框架最终被开发出来之后, 它会如何被使用. 我写了一个框架使用的 Demo 示例, 如下所示.
///////// 使用方式一: 在业务代码中处理幂等 ////////////
// 接口调用方
Idempotence idempotence = new Idempotence();
String idempotenceId = idempotence.createId();
Order order = createOrderWithIdempotence(..., idempotenceId);
// 接口实现方
public class OrderController {
private Idempotence idempontence; // 依赖注入
public Order createOrderWithIdempotence(..., String idempotenceId) {
// 前置操作
boolean existed = idempotence.check(idempotenceId);
if (existed) {
// 两种处理方式:
// 1. 查询order, 并且返回;
// 2. 返回duplication operation Exception
}
idempotence.record(idempotenceId);
//...执行正常业务逻辑
}
public Order createOrder(...) {
//...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
///////// 使用方式二: 在框架层面处理幂等 //////////////
// 接口调用方
Idempotence idempotence = new Idempotence();
String idempotenceId = idempotence.createId();
//...通过feign框架将幂等号添加到http header中...
// 接口实现方
public class OrderController {
@IdempotenceRequired
public Order createOrder(...) {
//...
}
}
// 在AOP切面中处理幂等
@Aspect
public class IdempotenceSupportAdvice {
@Autowired
private Idempotence idempotence;
@Pointcut("@annotation(com.xzg.cd.idempotence.annotation.IdempotenceRequired)")
public void controllerPointcut() {
}
@Around(value = "controllerPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 从HTTP header中获取幂等号idempotenceId
// 前置操作
boolean existed = idempotence.check(idempotenceId);
if (existed) {
// 两种处理方式:
// 1. 查询order, 并且返回;
// 2. 返回duplication operation Exception
}
idempotence.record(idempotenceId)
Object result = joinPoint.proceed();<br />return result;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
结合刚刚的 Demo, 从使用的角度来说, 幂等框架的主要处理流程是这样的. 接口调用方生成幂等号, 并且跟随接口请求, 将幂等号传递给接口实现方. 接口实现方接收到接口请求之后, 按照约定, 从 HTTP Header 或者接口参数中, 解析出幂等号, 然后通过幂等号查询幂等框架. 如果幂等号已经存在, 说明业务已经执行或正在执行, 则直接返回; 如果幂等号不存在, 说明业务没有执行过, 则记录幂等号, 继续执行业务.
对于幂等框架, 再来看下, 它都有哪些非功能性需求.
在易用性方面, 希望框架接入简单方便, 学习成本低. 只需编写简单的配置以及少许代码, 就能完成接入. 除此之外, 框架最好对业务代码低侵入松耦合, 在统一的地方(比如 Spring AOP 中)接入幂等框架, 而不是将它耦合在业务代码中.
在性能方面, 针对每个幂等接口, 在正式处理业务逻辑之前, 都要添加保证幂等的处理逻辑. 这或多或少地会增加接口请求的响应时间. 而对于响应时间比较敏感的接口服务来说, 要让幂等框架尽可能低延迟, 尽可能减少对接口请求本身响应时间的影响.
在容错性方面, 跟限流框架相同, 不能因为幂等框架本身的异常, 导致接口响应异常, 影响服务本身的可用性. 所以, 幂等框架要有高度的容错性. 比如, 存储幂等号的外部存储器挂掉了, 幂等逻辑无法正常运行, 这个时候业务接口也要能正常服务才行.
# 94-项目实战二:设计实现一个通用的接口幂等框架(设计)
上一节介绍了幂等框架的一个重要需求场景, 接口超时重试. 为了避免同一业务被多次重复执行, 接口需要支持幂等特性. 同时还对功能性需求和非功能性需求做了梳理. 今天来讲解幂等框架的设计思路.
跟限流框架类似, 幂等框架的功能性需求也比较简单, 但要考虑处理的异常情况有很多, 比如业务代码异常, 业务系统宕机, 幂等框架异常. 今天重点讲解如何应对这些异常情况, 设计一个高度容错的幂等框架.
# 1.幂等处理正常流程
调用方从发起接口请求到接收到响应, 一般要经过三个阶段. 第一个阶段是调用方发送请求并被实现方接收, 第二个阶段是执行接口对应的业务逻辑, 第三个阶段是将执行结果返回给调用方. 为了实现接口幂等, 需要将幂等相关的逻辑, 添加在这三个阶段中.
正常情况下, 幂等号随着请求传递到接口实现方之后, 接口实现方将幂等号解析出来, 传递给幂等框架. 幂等框架先去数据库(比如 Redis)中查找这个幂等号是否已经存在. 如果存在, 说明业务逻辑已经或者正在执行, 就不要重复执行了. 如果幂等号不存在, 就将幂等号存储在数据库中, 然后再执行相应的业务逻辑.
正常情况下, 幂等处理流程是非常简单的, 难点在于如何应对异常情况. 在这三个阶段中, 如果第一个阶段出现异常, 比如发送请求失败或者超时, 幂等号还没有记录下来, 重试请求会被执行, 符合我们的预期. 如果第三个阶段出现异常, 业务逻辑执行完成了, 只是在发送结果给调用方的时候, 失败或者超时了, 这个时候, 幂等号已经记录下来, 重试请求不会被执行, 也符合我们的预期. 也就是说, 第一, 第三阶段出现异常, 上述的幂等处理逻辑都可以正确应对.
但是, 如果第二个阶段业务执行的过程出现异常, 处理起来就复杂多了. 接下来就看下幂等框架该如何应对这一阶段的各种异常. 下面分了三类异常来讲解, 它们分别是业务代码异常, 业务系统宕机, 幂等框架异常.
# 2.业务代码异常处理
当业务代码在执行过程中抛出异常的时候, 是否应该认定为业务处理失败, 然后将已经记录的幂等号删除, 允许重新执行业务逻辑呢?
对于这个问题, 要分业务异常和系统异常来区分对待. 那什么是业务异常? 什么是系统异常呢? 举个例子解释一下. 比如, A 用户发送消息给 B 用户, 但是查询 B 用户不存在, 抛出 UserNotExisting 异常, 这种业务上不符合预期叫做业务异常. 因为数据库挂掉了, 业务代码访问数据库时, 就会报告数据库异常, 这种非业务层面的, 系统级的异常, 叫做系统异常.
遇到业务异常(比如 UserNotExisting 异常), 不删除已经记录的幂等号, 不允许重新执行同样的业务逻辑, 因为再次重新执行也是徒劳的, 还是会报告异常. 相反, 遇到系统异常(比如数据库访问异常), 将已经记录的幂等号删除, 允许重新执行这段业务逻辑. 因为在系统级问题修复之后(比如数据库恢复了), 重新执行之前失败的业务逻辑, 就有可能会成功.
实际上, 为了让幂等框架尽可能的灵活, 低侵入业务逻辑, 发生异常(不管是业务异常还是系统异常), 是否允许再重试执行业务逻辑, 交给开发这块业务的工程师来决定是最合适的了, 毕竟他最清楚针对每个异常该如何处理. 而幂等框架本身不参与这个决定, 它只需要提供删除幂等号的接口, 由业务工程师来决定遇到异常的时候, 是否需要调用这个删除接口, 删除已经记录的幂等号.
# 3.业务系统宕机处理
刚刚分析的是代码异常, 再来看下, 如果在业务处理的过程中, 业务系统宕机了(可以简单理解为部署了业务系统的机器宕机了), 幂等框架是否还能正确工作呢?
如果幂等号已经记录下了, 但是因为机器宕机, 业务还来得及执行, 按照刚刚的幂等框架的处理流程, 即便机器重启, 业务也不会再被触发执行了, 这个时候该怎么办呢? 除此之外, 如果记录幂等号成功了, 但是在捕获到系统异常之后, 要删除幂等号之前, 机器宕机了, 这个时候又该怎么办?
如果希望幂等号的记录和业务的执行完全一致, 就要把它们放到一个事务中. 执行成功, 必然会记录幂等号; 执行失败, 幂等号记录也会被自动回滚. 因为幂等框架和业务系统各自使用独立的数据库来记录数据, 所以这里涉及的事务属于分布式事务. 如果为了解决这个问题, 引入分布式事务, 那幂等框架的开发难度提高了很多, 并且框架使用起来也复杂了很多, 性能也会有所损失.
针对这个问题, 还有另外一种解决方案. 那就是, 在存储业务数据的业务数据库( 比如 MySQL)中, 建一张表来记录幂等号. 幂等号先存储到业务数据库中, 然后再同步给幂等框架的 Redis 数据库. 这样做的好处是, 不需要引入分布式事务框架, 直接利用业务数据库本身的事务属性, 保证业务数据和幂等号的写入操作, 要么都成功, 要么都失败. 不过, 这个解决方案会导致幂等逻辑, 跟业务逻辑没有完全解耦, 不符合之前讲到的低侵入, 松耦合的设计思想.
实际上, 做工程不是做理论. 对于这种极少发生的异常, 在工程中, 能够做到, 在出错时能及时发现问题, 能够根据记录的信息人工修复就可以了. 虽然看起来解决方案不优雅, 不够智能, 不够自动化, 但是这比编写一大坨复杂的代码逻辑来解决, 要好使得多. 所以建议业务系统记录 SQL 的执行日志, 在日志中附加上幂等号. 这样就能在机器宕机时, 根据日志来判断业务执行情况和幂等号的记录是否一致.
# 4.幂等框架异常处理
前面提到, 限流框架本身的异常, 不能导致接口响应异常. 那对于幂等框架来说, 是否也适用这条设计原则呢?
对于限流来说, 限流框架执行异常(比如, Redis 访问超时或者访问失败), 可以触发服务降级, 让限流功能暂时不起作用, 接口还能正常执行. 如果大量的限流接口调用异常, 在具有完善监控的情况下, 这些异常很快就会被运维发现并且修复, 所以短暂的限流失效, 也不会对业务系统产生太多影响. 毕竟限流只是一个针对突发情况的保护机制, 平时并不起作用. 如果偶尔的极个别的限流接口调用异常, 本不应该被放过的几个接口请求, 因为限流的暂时失效被放过了, 对于这种情况, 绝大部分业务场景都是可以接受的. 毕竟限流不可能做到非常精确, 多放过一两个接口请求几乎没影响.
对于幂等来说, 尽管它应对的也是超时重试等特殊场景, 但如果本不应该重新执行的业务逻辑, 因为幂等功能的暂时失效, 被重复执行了, 就会导致业务出错(比如, 多次执行转账, 钱多转了). 对于这种情况, 绝大部分业务场景都是无法接受的. 所以, 在幂等逻辑执行异常时, 选择让接口请求也失败, 相应的业务逻辑就不会被重复执行了. 毕竟接口请求失败(比如转钱没转成功), 比业务执行出错(比如多转了钱), 修复的成本要低很多.
# 95-项目实战二:设计实现一个通用的接口幂等框架(实现)
上一节讲解了幂等框架的设计思路. 在正常情况下, 幂等框架的处理流程是比较简单的. 调用方生成幂等号, 传递给实现方, 实现方记录幂等号或者用幂等号判重. 但是, 幂等框架要处理的异常情况很多, 这也是设计的复杂之处和难点之处. 比如, 代码运行异常, 业务系统宕机, 幂等框架异常.
虽然幂等框架要处理的异常很多, 但考虑到开发成本以及简单易用性, 对某些异常的处理在工程上做了妥协, 交由业务系统或者人工介入处理. 这样就大大简化了幂等框架开发的复杂度和难度.
今天针对幂等框架的设计思路, 讲解如何编码实现. 跟限流框架的讲解相同, 对于幂等框架, 也会还原它的整个开发过程, 从 V1 版本需求, 最小原型代码讲起, 然后讲解如何 review 代码发现问题, 重构代码解决问题, 最终得到一份易读, 易扩展, 易维护, 灵活, 可测试的高质量代码实现.
# 1.V1版本功能需求
上一节给出的设计思路比较零散, 重点还是在讲设计的缘由, 为什么要这么设计. 今再重新整理一下, 经过上一节的分析梳理最终得到的设计思路. 虽然上一节的分析很复杂, 但思从深而行从简, 最终得到的幂等框架的设计思路是很简单的, 主要包含下面这样两个主要的功能开发点:
- 实现生成幂等号的功能;
- 实现存储, 查询, 删除幂等号的功能.
因为功能非常简单, 所以就不再进一步裁剪了. 在 V1 版本中, 会实现上面罗列的所有功能. 针对这两个功能点, 先来说下实现思路.
先来看, 如何生成幂等号.
幂等号用来标识两个接口请求是否是同一个业务请求, 换句话说, 两个接口请求是否是重试关系, 而非独立的两个请求. 接口调用方需要在发送接口请求的同时, 将幂等号一块传递给接口实现方. 那如何来生成幂等号呢? 一般有两种生成方式. 一种方式是集中生成并且分派给调用方, 另一种方式是直接由调用方生成.
对于第一种生成方式, 需要部署一套幂等号的生成系统, 并且提供相应的远程接口(Restful 或者 RPC 接口), 调用方通过调用远程接口来获取幂等号. 这样做的好处是, 对调用方完全隐藏了幂等号的实现细节. 当需要改动幂等号的生成算法时, 调用方不需要改动任何代码.
对于第二种生成方式, 调用方按照跟接口实现方预先商量好的算法, 自己来生成幂等号. 这种实现方式的好处在于, 不用像第一种方式那样调用远程接口, 所以执行效率更高. 但一旦需要修改幂等号的生成算法, 就需要修改每个调用方的代码.
并且, 每个调用方自己实现幂等号的生成算法也会有问题. 一方面, 重复开发, 违反 DRY 原则. 另一方面, 工程师的开发水平层次不齐, 代码难免会有 bug. 除此之外, 对于复杂的幂等号生成算法, 比如依赖外部系统 Redis 等, 显然更加适合上一种实现方式, 可以避免调用方为了使用幂等号引入新的外部系统.
权衡来讲, 既考虑到生成幂等号的效率, 又考虑到代码维护的成本, 选择第二种实现方式, 并且在此基础上做些改进, 由幂等框架来统一提供幂等号生成算法的代码实现, 并封装成开发类库, 提供给各个调用方复用. 除此之外, 还希望生成幂等号的算法尽可能的简单, 不依赖其他外部系统.
实际上, 对于幂等号的唯一要求就是全局唯一. 全局唯一 ID 的生成算法有很多. 比如, 简单点的有取 UUID, 复杂点的可以把应用名拼接在 UUID 上, 方便做问题排查. 总体上来讲, 幂等号的生成算法并不难.
再来看, 如何实现幂等号的存储, 查询和删除.
从现在的需求来看, 幂等号只是为了判重. 在数据库中, 只需要存储一个幂等号就可以, 不需要太复杂的存储结构, 所以不选择使用复杂的关系型数据库, 而是选择使用更加简单的, 读写更加快速的键值数据库, 比如 Redis.
在幂等判重逻辑中, 需要先检查幂等号是否存在. 如果没有存在, 再将幂等号存储进 Redis. 多个线程(同一个业务实例的多个线程)或者多进程(多个业务实例)同时执行刚刚的 "检查-设置" 逻辑时, 就会存在竞争关系(竞态, race condition). 比如, A 线程检查幂等号不存在, 在 A 线程将幂等号存储进 Redis 之前, B 线程也检查幂等号不存在, 这样就会导致业务被重复执行. 为了避免这种情况发生, 要给 "检查-设置" 操作加锁, 让同一时间只有一个线程能执行. 除此之外, 为了避免多进程之间的竞争, 普通的线程锁还不起作用, 需要分布式锁.
引入分布式锁会增加开发的难度和复杂度, 而 Redis 本身就提供了把 "检查-设置" 操作作为原子操作执行的命令: setnx(key, value). 它先检查 key 是否存在, 如果存在, 则返回结果 0; 如果不存在, 则将 key 值存下来, 并将值设置为 value, 返回结果 1. 因为 Redis 本身是单线程执行命令的, 所以不存在刚刚讲到的并发问题.
# 2.最小原型代码实现
V1 版本要实现的功能和实现思路, 现在已经很明确了. 现在来看下具体的代码实现. 还是跟限流框架同样的实现方法, 先不考虑设计和代码质量, 怎么简单怎么来, 先写出 MVP 代码, 然后基于这个最简陋的版本做优化重构.
V1 版本的功能非常简单, 用一个类就能搞定, 代码如下所示. 只用了不到 30 行代码, 就搞定了一个框架, 是不是觉得有点不可思议. 对于这段代码, 可以先思考下, 有哪些值得优化的地方.
public class Idempotence {
private JedisCluster jedisCluster;
public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
String[] addressArray= redisClusterAddress.split(";");
Set<HostAndPort> redisNodes = new HashSet<>();
for (String address : addressArray) {
String[] hostAndPort = address.split(":");
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
}
this.jedisCluster = new JedisCluster(redisNodes, config);
}
public String genId() {
return UUID.randomUUID().toString();
}
public boolean saveIfAbsent(String idempotenceId) {
Long success = jedisCluster.setnx(idempotenceId, "1");
return success == 1;
}
public void delete(String idempotenceId) {
jedisCluster.del(idempotenceId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 3.Review最小原型代码
尽管 MVP 代码很少, 但仔细推敲, 也有很多值得优化的地方. 现在就站在 Code Reviewer 的角度, 分析一下这段代码. 我把我的所有意见都放到代码注释中了, 可以对照着代码一块看下.
public class Idempotence {
// comment-1: 如果要替换存储方式, 是不是很麻烦呢?
private JedisCluster jedisCluster;
// comment-2: 如果幂等框架要跟业务系统复用jedisCluster连接呢?
// comment-3: 是不是应该注释说明一下redisClusterAddress的格式, 以及config是否可以传递进null呢?
public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
// comment-4: 这段逻辑放到构造函数里, 不容易写单元测试呢
String[] addressArray= redisClusterAddress.split(";");
Set<HostAndPort> redisNodes = new HashSet<>();
for (String address : addressArray) {
String[] hostAndPort = address.split(":");
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
}
this.jedisCluster = new JedisCluster(redisNodes, config);
}
// comment-5: generateId()是不是比缩写要好点?
// comment-6: 根据接口隔离原则, 这个函数跟其他函数的使用场景完全不同, 这个函数主要用在调用方, 其他函数用在实现方, 是不是应该分别放到两个类中?
public String genId() {
return UUID.randomUUID().toString();
}
// comment-7: 返回值的意义是不是应该注释说明一下?
public boolean saveIfAbsent(String idempotenceId) {
Long success = jedisCluster.setnx(idempotenceId, "1");
return success == 1;
}
public void delete(String idempotenceId) {
jedisCluster.del(idempotenceId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
总结一下, MVP 代码主要涉及下面这样几个问题.
- 代码可读性问题: 有些函数的参数和返回值的格式和意义不够明确, 需要注释补充解释一下. genId() 函数使用了缩写, 全拼 generateId() 可能更好些!
- 代码可扩展性问题: 按照现在的代码实现方式, 如果改变幂等号的存储方式和生成算法, 代码修改起来会比较麻烦. 除此之外, 基于接口隔离原则, 应该将 genId() 函数跟其他函数分离开来, 放到两个类中. 独立变化, 隔离修改, 更容易扩展!
- 代码可测试性问题: 解析 Redis Cluster 地址的代码逻辑较复杂, 但因为放到了构造函数中, 无法对它编写单元测试.
- 代码灵活性问题: 业务系统有可能希望幂等框架复用已经建立好的 jedisCluster, 而不是单独给幂等框架创建一个 jedisCluster.
# 4.重构最小原型代码
实际上, 问题找到了, 修改起来就容易多了. 针对刚刚罗列的几个问题, 对 MVP 代码进行重构, 重构之后的代码如下所示.
// 代码目录结构
com.xzg.cd.idempotence
--Idempotence
--IdempotenceIdGenerator(幂等号生成类)
--IdempotenceStorage(接口: 用来读写幂等号)
--RedisClusterIdempotenceStorage(IdempotenceStorage的实现类)
2
3
4
5
6
// 每个类的代码实现
public class Idempotence {
private IdempotenceStorage storage;
public Idempotence(IdempotenceStorage storage) {
this.storage = storage;
}
public boolean saveIfAbsent(String idempotenceId) {
return storage.saveIfAbsent(idempotenceId);
}
public void delete(String idempotenceId) {
storage.delete(idempotenceId);
}
}
public class IdempotenceIdGenerator {
public String generateId() {
return UUID.randomUUID().toString();
}
}
public interface IdempotenceStorage {
boolean saveIfAbsent(String idempotenceId);
void delete(String idempotenceId);
}
public class RedisClusterIdempotenceStorage {
private JedisCluster jedisCluster;
/**
* Constructor
* @param redisClusterAddress the format is 128.91.12.1:3455;128.91.12.2:3452;289.13.2.12:8978
* @param config should not be null
*/
public RedisIdempotenceStorage(String redisClusterAddress, GenericObjectPoolConfig config) {
Set<HostAndPort> redisNodes = parseHostAndPorts(redisClusterAddress);
this.jedisCluster = new JedisCluster(redisNodes, config);
}
public RedisIdempotenceStorage(JedisCluster jedisCluster) {
this.jedisCluster = jedisCluster;
}
/**
* Save {@idempotenceId} into storage if it does not exist.
* @param idempotenceId the idempotence ID
* @return true if the {@idempotenceId} is saved, otherwise return false
*/
public boolean saveIfAbsent(String idempotenceId) {
Long success = jedisCluster.setnx(idempotenceId, "1");
return success == 1;
}
public void delete(String idempotenceId) {
jedisCluster.del(idempotenceId);
}
@VisibleForTesting
protected Set<HostAndPort> parseHostAndPorts(String redisClusterAddress) {
String[] addressArray= redisClusterAddress.split(";");
Set<HostAndPort> redisNodes = new HashSet<>();
for (String address : addressArray) {
String[] hostAndPort = address.split(":");
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
}
return redisNodes;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
接下来再总结罗列一下, 针对之前发现的问题, 都做了哪些代码改动. 主要有下面这样几点, 可以结合着代码一块看下.
在代码可读性方面, 对构造函数, saveIfAbsense() 函数的参数和返回值做了注释, 并且将 genId() 函数改为全拼 generateId(). 不过对于这个函数来说, 缩写实际上问题也不大.
在代码可扩展性方面, 按照基于接口而非实现的编程原则, 将幂等号的读写独立出来, 设计成 IdempotenceStorage 接口和 RedisClusterIdempotenceStorage 实现类. RedisClusterIdempotenceStorage 实现了基于 Redis Cluster 的幂等号读写. 如果需要替换新的幂等号读写方式, 比如基于单个 Redis 而非 Redis Cluster, 就可以再定义一个实现了 IdempotenceStorage 接口的实现类: RedisIdempotenceStorage.
除此之外, 按照接口隔离原则, 将生成幂等号的代码抽离出来, 放到 IdempotenceIdGenerator 类中. 这样, 调用方只需要依赖这个类的代码就可以了. 幂等号生成算法的修改, 跟幂等号存储逻辑的修改, 两者完全独立, 一个修改不会影响另外一个.
在代码可测试性方面, 把原本放在构造函数中的逻辑抽离出来, 放到了 parseHostAndPorts() 函数中. 这个函数本应该是 Private 访问权限的, 但为了方便编写单元测试, 把它设置为成了 Protected 访问权限, 并且通过注解 @VisibleForTesting 做了标明.
在代码灵活性方面, 为了方便复用业务系统已经建立好的 jedisCluster, 提供了一个新的构造函数, 支持业务系统直接传递 jedisCluster 来创建 Idempotence 对象.
# 96-项目实战三:设计实现一个支持自定义规则的灰度发布组件(分析)
接下来再实战一个新的项目: 灰度发布组件. 还是老套路, 把它分为分析, 设计, 实现三个部分, 对应三节来讲解. 今天对灰度发布组件进行需求分析, 搞清楚这个组件应该具有哪些功能性和非功能性需求.
# 1.需求场景
还记得之前接口限流和幂等框架的项目背景吗? 我们开发了一个公共服务平台, 提供公共业务功能, 给其他产品的后端系统调用, 避免重复开发相同的业务代码.
最初, 公共服务平台提供的是, 基于某个开源 RPC 框架的 RPC 格式的接口. 在上线一段时间后, 就发现这个开源 RPC 框架的 Bug 很多, 多次因为框架本身的 Bug, 导致整个公共服务平台的接口不可用, 但又因为团队成员对框架源码不熟悉, 并且框架的代码质量本身也不高, 排查, 修复起来花费了很长时间, 影响面非常大. 所以, 评估下来, 觉着这个框架的可靠性不够, 维护成本, 二次开发成本都太高, 最终决定替换掉它.
对于引入新的框架, 要求是成熟, 简单, 并且与现有的技术栈(Spring)相吻合. 这样即便出了问题, 也能利用之前积累的知识, 经验来快速解决. 所以决定直接使用 Spring 框架来提供 RESTful 格式的远程接口.
把 RPC 接口替换成 RESTful 接口, 除了需要修改公共服务平台的代码之外, 调用方的接口调用代码也要做相应的修改. 除此之外, 对于公共服务平台的代码, 尽管只是改动接口暴露方式, 队业务代码基本上没有改动, 但是也并不能保证就完全不出问题. 所以为了保险起见, 希望灰度替换掉老的 RPC 服务, 而不是一刀切, 在某个时间点上, 让所有的调用方一下子都变成调用新的 Resful 接口.
来看下具体如何来做.
因为替换的过程是灰度的, 所以老的 RPC 服务不能下线, 同时还要部署另外一套新的 RESTful 服务. 先让业务不是很重要, 流量不大的某个调用方, 替换成调用新的 RESTful 接口. 经过这个调用方一段时间的验证之后, 如果新的 RESTful 接口没有问题, 再逐步让其他调用方, 替换成调用新的 RESTful 接口.
如果万一中途出现问题, 就需要将调用方的代码回滚, 再重新部署, 这就会导致调用方一段时间内服务不可用. 而且, 如果新的代码还包含调用方自身新的业务代码, 简单通过 Git 回滚代码重新部署, 会导致新的业务代码也被回滚. 所以, 为了避免这种情况的发生, 就得手动将调用新的 RESTful 接口的代码删除, 再改回为调用老的 RPC 接口.
除此之外, 为了不影响调用方本身业务的开发进度, 调用方基于回滚之后的老代码, 来做新功能开发, 那替换成新的 RESTful 接口的那部分代码, 要想再重新 merge 回去就比较难了, 有可能会出现代码冲突, 需要再重新开发.
怎么解决代码回滚成本比较高的问题呢?
在替换新的接口调用方式的时候, 调用方并不直接将调用 RPC 接口的代码逻辑删除, 而是新增调用 RESTful 接口的代码, 通过一个功能开关, 灵活切换走老的代码逻辑还是新的代码逻辑. 代码示例如下所示. 如果 callRestfulApi 为 true, 就会走新的代码逻辑, 调用 RESTful 接口, 相反就会走老的代码逻辑, 继续调用 RPC 接口.
boolean callRestfulApi = true;
if (!callRestfulApi) {
// 老的调用RPC接口的代码逻辑
} else {
// 新的调用Resful接口的代码逻辑
}
2
3
4
5
6
7
不过, 更改 callRestfulApi 的值需要修改代码, 而修改代码就要重新部署, 这样的设计还是不够灵活. 优化的方法, 我想你应该已经想到了, 把这个值放到配置文件或者配置中心就可以了.
为了更加保险, 不只是使用功能开关做新老接口调用方式的切换, 还希望调用方在替换某个接口的时候, 先让小部分接口请求, 调用新的 RESTful 接口, 剩下的大部分接口请求, 还是调用老的 RPC 接口, 验证没有问题之后, 再逐步加大调用新接口的请求比例, 最终将所有的接口请求, 都替换成调用新的接口. 这就是所谓的"灰度".
那这个灰度功能又该如何实现呢?
首先, 要决定使用什么来做灰度, 也就是灰度的对象. 可以针对请求携带的时间戳信息, 业务 ID 等信息, 按照区间, 比例或者具体的值来做灰度.
假设, 要灰度的是根据用户 ID 查询用户信息接口. 接口请求会携带用户 ID 信息, 所以就可以把用户 ID 作为灰度的对象. 为了实现逐渐放量, 先配置用户 ID 是 918, 879, 123(具体的值)的查询请求调用新接口, 验证没有问题之后, 再扩大范围, 让用户 ID 在 1020~1120(区间值)之间的查询请求调用新接口.
如果验证之后还是没有问题, 再继续扩大范围, 让 10% 比例(比例值)的查询请求调用新接口(对应用户 ID 跟 10 取模求余小于 1 的请求). 以此类推, 灰度范围逐步扩大到 20%, 30%, 50% 直到 100%. 当灰度比例达到 100%, 并且运行一段时间没有问题之后, 调用方就可以把老的代码逻辑删除掉了.
实际上, 类似的灰度需求场景还有很多. 比如在金融产品的清结算系统中, 修改了清结算的算法. 为了安全起见, 可以灰度替换新的算法, 把贷款 ID 作为灰度对象, 先对某几个贷款应用新的算法, 如果没有问题, 再继续按照区间或者比例, 扩大灰度范围.
除此之外, 为了保证代码万无一失, 提前做好预案, 添加或者修改一些复杂功能, 核心功能, 即便不做灰度, 也建议通过功能开关, 灵活控制这些功能的上下线. 在不需要重新部署和重启系统的情况, 做到快速回滚或新老代码逻辑的切换.
# 2.需求分析
从实现的角度来讲, 调用方只需要把灰度规则和功能开关, 按照某种事先约定好的格式, 存储到配置文件或者配置中心, 在系统启动的时候, 从中读取配置到内存中, 之后, 看灰度对象是否落在灰度范围内, 以此来判定是否执行新的代码逻辑. 但为了避免每个调用方都重复开发, 我们把功能开关和灰度相关的代码, 抽象封装为一个灰度组件, 提供给各个调用方来复用.
这里需要强调一点, 这里的灰度, 是代码级别的灰度, 目的是保证项目质量, 规避重大代码修改带来的不确定性风险. 实际上平时经常讲的灰度, 一般都是产品层面或者系统层面的灰度.
所谓产品层面, 有点类似 A/B Testing, 让不同的用户看到不同的功能, 对比两组用户的使用体验, 收集数据, 改进产品. 所谓系统层面的灰度, 往往不在代码层面上实现, 一般是通过配置负载均衡或者 API-Gateway, 来实现分配流量到不同版本的系统上. 系统层面的灰度也是为了平滑上线功能, 但比起讲到的代码层面的灰度, 就没有那么细粒度了, 开发和运维成本也相对要高些.
现在就来具体看下, 灰度组件都有哪些功能性需求.
还是从使用的角度来分析. 组件使用者需要设置一个 key 值, 来唯一标识要灰度的功能, 然后根据自己业务数据的特点, 选择一个灰度对象(比如用户 ID), 在配置文件或者配置中心中, 配置这个 key 对应的灰度规则和功能开关. 配置的格式类似下面这个样子:
features:
- key: call_newapi_getUserById
enabled: true // enabled为true时, rule才生效
rule: {893,342,1020-1120,%30} // 按照用户ID来做灰度
- key: call_newapi_registerUser
enabled: true
rule: {1391198723, %10} // 按照手机号来做灰度
- key: newalgo_loan
enabled: true
rule: {0-1000} // 按照贷款(loan)的金额来做灰度
2
3
4
5
6
7
8
9
10
灰度组件在业务系统启动的时候, 会将这个灰度配置, 按照事先定义的语法, 解析并加载到内存对象中, 业务系统直接使用组件提供的灰度判定接口, 给业务系统使用, 来判定某个灰度对象是否灰度执行新的代码逻辑. 配置的加载解析, 灰度判定逻辑这部分代码, 都不需要业务系统来从零开发.
public interface DarkFeature {
boolean enabled();
boolean dark(String darkTarget); //darkTarget是灰度对象, 比如前面提到的用户ID, 手机号码, 金额等
}
2
3
4
所以, 总结一下的话, 灰度组件跟限流框架很类似, 它也主要包含两部分功能: 灰度规则配置解析和提供编程接口(DarkFeature)判定是否灰度.
跟限流框架类似, 除了功能性需求, 还要分析非功能性需求. 不过因为前面已经有了限流框架的非功能性需求的讲解, 对于灰度组件的非功能性需求.
# 97-项目实战三:设计实现一个支持自定义规则的灰度发布组件(设计)
上一节介绍了灰度组件的一个需求场景, 将公共服务平台的 RPC 接口, 灰度替换为新的 RESTful 接口, 通过灰度逐步放量, 支持快速回滚等手段, 来规避代码质量问题带来的不确定性风险.
跟前面两个框架类似, 灰度组件的功能性需求也比较简单. 上一节做了简单分析, 今天再介绍一下, 这个组件的非功能性需求, 以及如何通过合理的设计来满足这些非功能性需求.
# 1.非功能性需求
对于限流框架, 主要从易用性, 扩展性, 灵活性, 性能, 容错性这几个方面, 来分析它的非功能性需求. 对于灰度组件, 同样也从这几个方面来分析.
# (1)易用性
在前面讲到限流框架和幂等框架的时候, 都提到了 "低侵入松耦合" 的设计思想. 因为框架需要集成到业务系统中使用, 所以希望它尽可能低侵入, 与业务代码松耦合, 替换, 移除起来更容易些. 因为接口的限流和幂等跟具体的业务是无关的, 可以把限流和幂等相关的逻辑, 跟业务代码解耦, 统一放到公共的地方来处理(比如 Spring AOP 切面中).
但是, 对于灰度来说, 实现的灰度功能是代码级别的细粒度的灰度, 而替代掉原来的 if-else 逻辑, 是针对一个业务一个业务来做的, 跟业务强相关, 要做到跟业务代码完全解耦, 是不现实的. 所以在侵入性这一点上, 灰度组件只能做妥协, 容忍一定程度的侵入.
除此之外, 在灰度的过程中, 要不停地修改灰度规则, 在测试没有出现问题的情况下, 逐渐放量. 从运维的角度来说, 如果每次修改灰度规则都要重启系统, 显然是比较麻烦的. 所以, 希望支持灰度规则的热更新, 也就是说, 当在配置文件中, 修改了灰度规则之后, 系统在不重启的情况下会自动加载, 更新灰度规则.
# (2)扩展性,灵活性
跟限流框架一样, 希望支持不同格式(JSON, YAML, XML 等), 不同存储方式(本地配置文件, Redis, Zookeeper, 或者自研配置中心等)的灰度规则配置方式. 这一点在限流框架中已经详细讲过了, 在灰度组件中就不重复讲解了.
除此之外, 对于灰度规则本身, 在上一节的示例中, 定义了三种灰度规则语法格式: 具体值(比如 893), 区间值(比如 1020-1120), 比例值(比如 %30). 不过这只能处理比较简单的灰度规则. 如果要支持更加复杂的灰度规则, 比如只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度, 现在的灰度规则语法就无法支持了. 所以, 如何支持更加灵活的, 复杂的灰度规则, 也是设计实现的重点和难点.
# (3)性能
在性能方面, 灰度组件的处理难度, 并不像限流框架那么高. 在限流框架中, 对于分布式限流模式, 接口请求访问计数存储在中心存储器中, 比如 Redis. 而 Redis 本身的读写性能以及限流框架与 Redis 的通信延迟, 都会很大地影响到限流本身的性能, 进而影响到接口响应时间. 所以对于分布式限流来说, 低延迟高性能是设计实现的难点和重点.
但是, 对于灰度组件来说, 灰度的判断逻辑非常简单, 而且不涉及访问外部存储, 所以性能一般不会有太大问题. 不过, 仍然需要把灰度规则组织成快速查找的数据结构, 能够支持快速判定某个灰度对象(darkTarget, 比如用户 ID)是否落在灰度规则设定的区间内.
# (4)容错性
在限流框架中, 要求高度容错, 不能因为框架本身的异常, 导致接口响应异常. 从业务上来讲, 一般能容忍限流框架的暂时, 小规模的失效, 所以限流框架对于异常的处理原则是, 尽可能捕获所有异常, 并且内部 "消化" 掉, 不要往上层业务代码中抛出.
对于幂等框架来说, 不能容忍框架暂时, 小规模的失效, 因为这种失效会导致业务有可能多次被执行, 发生业务数据的错误. 所以, 幂等框架对于异常的处理原则是, 按照 fail-fast 原则, 如果异常导致幂等逻辑无法正常执行, 让业务代码也中止. 因为业务执行失败, 比业务执行出错, 修复的成本更低.
对于灰度组件来说, 上面的两种对异常的处理思路都是可以接受的. 在灰度组件出现异常时, 既可以选择中止业务, 也可以选择让业务继续执行. 如果让业务继续执行, 本不应该被灰度到的业务对象, 就有可能被执行. 这是否能接受, 还是要看具体的业务. 不过个人倾向于采用类似幂等框架的处理思路, 在出现异常时中止业务.
# 2.框架设计思路
根据刚刚对灰度组件的非功能性需求分析, 以及跟限流框架, 幂等框架非功能性需求的对比, 可以看出, 在性能和容错性方面, 灰度组件并没有需要特别要处理的地方, 重点需要关注的是易用性, 扩展性, 灵活性. 详细来说, 主要包括这样两点: 支持更灵活, 更复杂的灰度规则和支持灰度规则热更新. 接下来, 下面就重点讲一下, 针对这两个重点问题的设计思路.
首先来看, 如何支持更灵活, 更复杂的灰度规则.
灰度规则的配置也是跟业务强相关的. 业务方需要根据要灰度的业务特点, 找到灰度对象(上节课中的 darkTarget, 比如用户 ID), 然后按照给出的灰度规则语法格式, 配置相应的灰度规则.
对于像刚刚提到的那种复杂的灰度规则(只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度), 通过定义语法规则来支持, 是很难实现的. 所以针对复杂灰度规则, 可以换个思路来实现.
我暂时想到了两种解决方法. 其中一种是使用规则引擎, 比如 Drools, 可以在配置文件中调用 Java 代码. 另一种是支持编程实现灰度规则, 这样做灵活性更高. 不过缺点是更新灰度规则需要更新代码, 重新部署.
对于大部分业务的灰度, 使用前面定义的最基本的语法规则(具体值, 区间值, 比例值)就能满足了. 对于极个别复杂的灰度规则, 借鉴 Spring 的编程式配置, 由业务方编程实现, 具体如何来做, 放到下一节的代码实现中讲解. 这样既兼顾了易用性, 又兼顾了灵活性.
之所以选择第二种实现方式, 而不是使用 Drools 规则引擎, 主要是出于不想为了不常用的功能, 引入复杂的第三方框架, 提高开发成本和灰度框架本身的学习成本.
其次来看, 如何实现灰度规则热更新.
规则热更新这样一个功能, 并非灰度组件特有的, 很多场景下都有类似的需求. 前面讲到性能计数器项目的时候, 也提到过这个需求.
灰度规则的热更新实现起来并不难. 创建一个定时器, 每隔固定时间(比如 1 分钟), 从配置文件中, 读取灰度规则配置信息, 并且解析加载到内存中, 替换掉老的灰度规则. 需要特别强调的是, 更新灰度规则, 涉及读取配置, 解析, 构建等一系列操作, 会花费比较长的时间, 不能因为更新规则, 就暂停了灰度服务. 所以, 在设计和实现灰度规则更新的时候, 要支持更新和查询并发执行. 具体如何来做, 留在下一节的实现中详细讲解.
# 98-项目实战三:设计实现一个支持自定义规则的灰度发布组件(实现)
上两节讲解了灰度组件的需求和设计思路. 不管是之前讲过的限流, 幂等框架, 还是现在正在讲的灰度组件, 这些框架, 组件, 类库的功能性需求都不复杂, 相反, 非功能性需求是开发的重点, 难点.
今天按照上节给出的灰度组件的设计思路, 讲解如何进行编码实现. 不过今天对实现的讲解, 跟前面两个实战项目有所不同. 在前面两个项目中, 都是手把手地从最基础的 MVP 代码讲起, 然后讲解如何 review 代码发现问题, 重构代码解决问题, 最终得到一份还算高质量的代码. 考虑到已经有前面两个项目的学习和锻炼了, 你应该对开发套路, 思考路径很熟悉了, 所以今天换个讲法, 就不从最基础的讲起了, 而是重点讲解实现思路.
# 1.灰度组件功能需求整理
针对上两节给出的开发需求和设计思路, 还是按照老套路, 从中剥离出 V1 版本要实现的内容. 这里把灰度组件的开发需求和设计思路, 重新整理罗列了一下, 放到了这里.
灰度规则的格式和存储方式
希望支持不同格式(JSON, YAML, XML 等), 不同存储方式(本地配置文件, Redis, Zookeeper, 或者自研配置中心等)的灰度规则配置方式. 实际上, 这一点跟之前的限流框架中限流规则的格式和存储方式完全一致, 代码实现也是相同的, 所以在接下来的讲解中, 就不重复啰嗦了.
灰度规则的语法格式
支持三种灰度规则语法格式: 具体值(比如 893), 区间值(比如 1020-1120), 比例值(比如 %30). 除此之外, 对于更加复杂的灰度规则, 比如只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度, 通过编程的方式来实现.
灰度规则的内存组织方式
类似于限流框架中的限流规则, 需要把灰度规则组织成支持快速查找的数据结构, 能够快速判定某个灰度对象(darkTarget, 比如用户 ID), 是否落在灰度规则设定的范围内.
灰度规则热更新
修改了灰度规则之后, 希望不重新部署和重启系统, 新的灰度规则就能生效, 所以需要支持灰度规则热更新.
在 V1 版本中, 对于第一点灰度规则的格式和存储方式, 只支持 YAML 格式本地文件的配置存储方式. 对于剩下的三点, 都要进行实现. 考虑到 V1 版本要实现的内容比较多, 分两步来实现代码, 第一步先将大的流程, 框架搭建好, 第二步再进一步添加, 丰富, 优化功能.
# 2.实现灰度组件基本功能
在第一步中, 先实现基于 YAML 格式的本地文件的灰度规则配置方式, 以及灰度规则热更新, 并且只支持三种基本的灰度规则语法格式. 基于编程实现灰度规则的方式, 留在第二步实现.
先把这个基本功能的开发需求, 用代码实现出来. 它的目录结构及其 Demo 示例如下所示. 代码非常简单, 只包含 4 个类. 接下来针对每个类再详细讲解一下.
// 代码目录结构
com.xzg.darklaunch
--DarkLaunch(框架的最顶层入口类)
--DarkFeature(每个feature的灰度规则)
--DarkRule(灰度规则)
--DarkRuleConfig(用来映射配置到内存中)
2
3
4
5
6
// Demo示例
public class DarkDemo {
public static void main(String[] args) {
DarkLaunch darkLaunch = new DarkLaunch();
DarkFeature darkFeature = darkLaunch.getDarkFeature("call_newapi_getUserById");
System.out.println(darkFeature.enabled());
System.out.println(darkFeature.dark(893));
}
}
2
3
4
5
6
7
8
9
// 灰度规则配置(dark-rule.yaml)放置在classpath路径下
features:
- key: call_newapi_getUserById
enabled: true
rule: {893,342,1020-1120,%30}
- key: call_newapi_registerUser
enabled: true
rule: {1391198723, %10}
- key: newalgo_loan
enabled: true
rule: {0-1000}
2
3
4
5
6
7
8
9
10
11
从 Demo 代码中可以看出, 对于业务系统来说, 灰度组件的两个直接使用的类是 DarkLaunch 类和 DarkFeature 类.
先来看 DarkLaunch 类. 这个类是灰度组件的最顶层入口类. 它用来组装其他类对象, 串联整个操作流程, 提供外部调用的接口.
DarkLaunch 类先读取灰度规则配置文件, 映射为内存中的 Java 对象(DarkRuleConfig), 然后再将这个中间结构, 构建成一个支持快速查询的数据结构(DarkRule). 除此之外, 它还负责定期更新灰度规则, 也就是前面提到的灰度规则热更新.
为了避免更新规则和查询规则的并发执行冲突, 在更新灰度规则的时候, 并非直接操作老的 DarkRule, 而是先创建一个新的 DarkRule, 然后等新的 DarkRule 都构建好之后, 再 "瞬间" 赋值给老的 DarkRule. 可以结合着下面的代码一块看下.
public class DarkLaunch {
private static final Logger log = LoggerFactory.getLogger(DarkLaunch.class);
private static final int DEFAULT_RULE_UPDATE_TIME_INTERVAL = 60; // in seconds
private DarkRule rule;
private ScheduledExecutorService executor;
public DarkLaunch(int ruleUpdateTimeInterval) {
loadRule();
this.executor = Executors.newSingleThreadScheduledExecutor();
this.executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
loadRule();
}
}, ruleUpdateTimeInterval, ruleUpdateTimeInterval, TimeUnit.SECONDS);
}
public DarkLaunch() {
this(DEFAULT_RULE_UPDATE_TIME_INTERVAL);
}
private void loadRule() {
// 将灰度规则配置文件dark-rule.yaml中的内容读取DarkRuleConfig中
InputStream in = null;
DarkRuleConfig ruleConfig = null;
try {
in = this.getClass().getResourceAsStream("/dark-rule.yaml");
if (in != null) {
Yaml yaml = new Yaml();
ruleConfig = yaml.loadAs(in, DarkRuleConfig.class);
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
}
if (ruleConfig == null) {
throw new RuntimeException("Can not load dark rule.");
}
// 更新规则并非直接在this.rule上进行,
// 而是通过创建一个新的DarkRule, 然后赋值给this.rule,
// 来避免更新规则和规则查询的并发冲突问题
DarkRule newRule = new DarkRule(ruleConfig);
this.rule = newRule;
}
public DarkFeature getDarkFeature(String featureKey) {
DarkFeature darkFeature = this.rule.getDarkFeature(featureKey);
return darkFeature;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
再来看下 DarkRuleConfig 类. 这个类功能非常简单, 只是用来将灰度规则映射到内存中. 具体的代码如下所示:
public class DarkRuleConfig {
private List<DarkFeatureConfig> features;
public List<DarkFeatureConfig> getFeatures() {
return this.features;
}
public void setFeatures(List<DarkFeatureConfig> features) {
this.features = features;
}
public static class DarkFeatureConfig {
private String key;
private boolean enabled;
private String rule;
// 省略getter, setter方法
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
从代码中可以看出来, DarkRuleConfig 类嵌套了一个内部类 DarkFeatureConfig. 这两个类跟配置文件的两层嵌套结构完全对应. 这里把对应关系标注在了下面的示例中, 可以对照着代码看下.
<!--对应DarkRuleConfig-->
features:<br/>- key: call_newapi_getUserById <!--对应DarkFeatureConfig-->
enabled: true
rule: {893,342,1020-1120,%30}
- key: call_newapi_registerUser <!--对应DarkFeatureConfig-->
enabled: true
rule: {1391198723, %10}
- key: newalgo_loan <!--对应DarkFeatureConfig-->
enabled: true
rule: {0-1000}
2
3
4
5
6
7
8
9
10
再来看下 DarkRule. DarkRule 包含所有要灰度的业务功能的灰度规则. 它用来支持根据业务功能标识(feature key), 快速查询灰度规则(DarkFeature). 代码也比较简单, 具体如下所示:
public class DarkRule {
private Map<String, DarkFeature> darkFeatures = new HashMap<>();
public DarkRule(DarkRuleConfig darkRuleConfig) {
List<DarkRuleConfig.DarkFeatureConfig> darkFeatureConfigs = darkRuleConfig.getFeatures();
for (DarkRuleConfig.DarkFeatureConfig darkFeatureConfig : darkFeatureConfigs) {
darkFeatures.put(darkFeatureConfig.getKey(), new DarkFeature(darkFeatureConfig));
}
}
public DarkFeature getDarkFeature(String featureKey) {
return darkFeatures.get(featureKey);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
最后来看下 DarkFeature 类. DarkFeature 类表示每个要灰度的业务功能的灰度规则. DarkFeature 将配置文件中灰度规则, 解析成一定的结构(比如 RangeSet), 方便快速判定某个灰度对象是否落在灰度规则范围内. 具体的代码如下所示:
public class DarkFeature {
private String key;
private boolean enabled;
private int percentage;
private RangeSet<Long> rangeSet = TreeRangeSet.create();
public DarkFeature(DarkRuleConfig.DarkFeatureConfig darkFeatureConfig) {
this.key = darkFeatureConfig.getKey();
this.enabled = darkFeatureConfig.getEnabled();
String darkRule = darkFeatureConfig.getRule().trim();
parseDarkRule(darkRule);
}
@VisibleForTesting
protected void parseDarkRule(String darkRule) {
if (!darkRule.startsWith("{") || !darkRule.endsWith("}")) {
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
}
String[] rules = darkRule.substring(1, darkRule.length() - 1).split(",");
this.rangeSet.clear();
this.percentage = 0;
for (String rule : rules) {
rule = rule.trim();
if (StringUtils.isEmpty(rule)) {
continue;
}
if (rule.startsWith("%")) {
int newPercentage = Integer.parseInt(rule.substring(1));
if (newPercentage > this.percentage) {
this.percentage = newPercentage;
}
} else if (rule.contains("-")) {
String[] parts = rule.split("-");
if (parts.length != 2) {
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
}
long start = Long.parseLong(parts[0]);
long end = Long.parseLong(parts[1]);
if (start > end) {
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
}
this.rangeSet.add(Range.closed(start, end));
} else {
long val = Long.parseLong(rule);
this.rangeSet.add(Range.closed(val, val));
}
}
}
public boolean enabled() {
return this.enabled;
}
public boolean dark(long darkTarget) {
boolean selected = this.rangeSet.contains(darkTarget);
if (selected) {
return true;
}
long reminder = darkTarget % 100;
if (reminder >= 0 && reminder < this.percentage) {
return true;
}
return false;
}
public boolean dark(String darkTarget) {
long target = Long.parseLong(darkTarget);
return dark(target);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 3.添加,优化灰度组件功能
在第一步中, 完成了灰度组件的基本功能. 在第二步中, 再实现基于编程的灰度规则配置方式, 用来支持更加复杂, 更加灵活的灰度规则.
需要对于第一步实现的代码, 进行一些改造. 改造之后的代码目录结构如下所示. 其中, DarkFeature, DarkRuleConfig 的基本代码不变, 新增了 IDarkFeature 接口, DarkLaunch, DarkRule 的代码有所改动, 用来支持编程实现灰度规则.
// 第一步的代码目录结构
com.xzg.darklaunch
--DarkLaunch(框架的最顶层入口类)
--DarkFeature(每个feature的灰度规则)
--DarkRule(灰度规则)
--DarkRuleConfig(用来映射配置到内存中)
// 第二步的代码目录结构
com.xzg.darklaunch
--DarkLaunch(框架的最顶层入口类, 代码有改动)
--IDarkFeature(抽象接口)
--DarkFeature(实现IDarkFeature接口, 基于配置文件的灰度规则, 代码不变)
--DarkRule(灰度规则, 代码有改动)
--DarkRuleConfig(用来映射配置到内存中, 代码不变)
2
3
4
5
6
7
8
9
10
11
12
13
14
先来看下 IDarkFeature 接口, 它用来抽象从配置文件中得到的灰度规则, 以及编程实现的灰度规则. 具体代码如下所示:
public interface IDarkFeature {
boolean enabled();
boolean dark(long darkTarget);
boolean dark(String darkTarget);
}
2
3
4
5
基于这个抽象接口, 业务系统可以自己编程实现复杂的灰度规则, 然后添加到 DarkRule 中. 为了避免配置文件中的灰度规则热更新时, 覆盖掉编程实现的灰度规则, 在 DarkRule 中, 对从配置文件中加载的灰度规则和编程实现的灰度规则分开存储. 按照这个设计思路, 对 DarkRule 类进行重构. 重构之后的代码如下所示:
public class DarkRule {
// 从配置文件中加载的灰度规则
private Map<String, IDarkFeature> darkFeatures = new HashMap<>();
// 编程实现的灰度规则
private ConcurrentHashMap<String, IDarkFeature> programmedDarkFeatures = new ConcurrentHashMap<>();
public void addProgrammedDarkFeature(String featureKey, IDarkFeature darkFeature) {
programmedDarkFeatures.put(featureKey, darkFeature);
}
public void setDarkFeatures(Map<String, IDarkFeature> newDarkFeatures) {
this.darkFeatures = newDarkFeatures;
}
public IDarkFeature getDarkFeature(String featureKey) {
IDarkFeature darkFeature = programmedDarkFeatures.get(featureKey);
if (darkFeature != null) {
return darkFeature;
}
return darkFeatures.get(featureKey);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
因为 DarkRule 代码有所修改, 对应地, DarkLaunch 的代码也需要做少许改动, 主要有一处修改和一处新增代码, 具体如下所示, 在代码中都做了注释, 就不再重复解释了.
public class DarkLaunch {
private static final Logger log = LoggerFactory.getLogger(DarkLaunch.class);
private static final int DEFAULT_RULE_UPDATE_TIME_INTERVAL = 60; // in seconds
private DarkRule rule = new DarkRule();
private ScheduledExecutorService executor;
public DarkLaunch(int ruleUpdateTimeInterval) {
loadRule();
this.executor = Executors.newSingleThreadScheduledExecutor();
this.executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
loadRule();
}
}, ruleUpdateTimeInterval, ruleUpdateTimeInterval, TimeUnit.SECONDS);
}
public DarkLaunch() {
this(DEFAULT_RULE_UPDATE_TIME_INTERVAL);
}
private void loadRule() {
InputStream in = null;
DarkRuleConfig ruleConfig = null;
try {
in = this.getClass().getResourceAsStream("/dark-rule.yaml");
if (in != null) {
Yaml yaml = new Yaml();
ruleConfig = yaml.loadAs(in, DarkRuleConfig.class);
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
}
if (ruleConfig == null) {
throw new RuntimeException("Can not load dark rule.");
}
// 修改: 单独更新从配置文件中得到的灰度规则, 不覆盖编程实现的灰度规则
Map<String, IDarkFeature> darkFeatures = new HashMap<>();
List<DarkRuleConfig.DarkFeatureConfig> darkFeatureConfigs = ruleConfig.getFeatures();
for (DarkRuleConfig.DarkFeatureConfig darkFeatureConfig : darkFeatureConfigs) {
darkFeatures.put(darkFeatureConfig.getKey(), new DarkFeature(darkFeatureConfig));
}
this.rule.setDarkFeatures(darkFeatures);
}
// 新增: 添加编程实现的灰度规则的接口
public void addProgrammedDarkFeature(String featureKey, IDarkFeature darkFeature) {
this.rule.addProgrammedDarkFeature(featureKey, darkFeature);
}
public IDarkFeature getDarkFeature(String featureKey) {
IDarkFeature darkFeature = this.rule.getDarkFeature(featureKey);
return darkFeature;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
灰度组件的代码实现就讲完了. 再通过一个 Demo 来看下, 目前实现的灰度组件该如何使用. 结合着 Demo, 再去理解上面的代码, 会更容易些. Demo 代码如下所示:
// 灰度规则配置(dark-rule.yaml), 放到classpath路径下
features:
- key: call_newapi_getUserById
enabled: true
rule: {893,342,1020-1120,%30}
- key: call_newapi_registerUser
enabled: true
rule: {1391198723, %10}
- key: newalgo_loan
enabled: true
rule: {0-100}
2
3
4
5
6
7
8
9
10
11
// 编程实现的灰度规则
public class UserPromotionDarkRule implements IDarkFeature {
@Override
public boolean enabled() {
return true;
}
@Override
public boolean dark(long darkTarget) {
// 灰度规则自己想怎么写就怎么写
return false;
}
@Override
public boolean dark(String darkTarget) {
// 灰度规则自己想怎么写就怎么写
return false;
}
}
// Demo
public class Demo {
public static void main(String[] args) {
DarkLaunch darkLaunch = new DarkLaunch(); // 默认加载classpath下dark-rule.yaml文件中的灰度规则
darkLaunch.addProgrammedDarkFeature("user_promotion", new UserPromotionDarkRule()); // 添加编程实现的灰度规则
IDarkFeature darkFeature = darkLaunch.getDarkFeature("user_promotion");
System.out.println(darkFeature.enabled());
System.out.println(darkFeature.dark(893));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 99-总结回顾:在实际软件开发中常用的设计思想, 原则和模式
到今天为止, 理论部分和实战部分都已经讲完了. 下面用两节复习一下前面学到的知识点. 跟前面的讲解相对应, 这两节课分别是针对理论部分和实战部分进行回顾总结.
今天先来回顾一下整个专栏的知识体系. 整个专栏围绕着编写高质量代码展开, 涵盖了代码设计的方方面面, 主要包括面向对象, 设计原则, 编码规范, 重构技巧, 设计模式这五个部分. 下面就从这五个方面, 一块把之前学过的知识点串一遍.

# 1.面向对象
相对于面向过程, 函数式编程, 面向对象是现在最主流的编程范式. 纯面向过程的编程方法, 现在已经不多见了, 而新的函数式编程, 因为它的应用场景比较局限, 所以大多作为面向对象编程的一种补充, 用在科学计算, 大数据处理等特殊领域.
它提供了丰富的特性, 比如封装, 抽象, 继承, 多态, 有助于实现复杂的设计思路, 是很多设计原则, 设计模式等编程实现的基础.
在面向对象这一部分, 要重点掌握面向对象的四大特性: 封装, 抽象, 继承, 多态, 以及面向对象编程与面向过程编程的区别. 需要特别注意的是, 在平时的面向对象编程开发中, 要避免编写出面向过程风格的代码.
除此之外, 还重点学习了面向对象分析(OOA), 设计(OOD), 编程(OOP). 其中, 面向对象分析就是需求分析, 面向对象设计是代码层面的设计, 输出的设计结果是类. 面向对象编程就是将设计的结果翻译成代码的过程.
在专栏中, 重点讲解了面向对象设计这一部分. 可以把面向对象设计分为四个环节: 划分职责并识别出有哪些类, 定义类及其属性和方法, 定义类之间的交互关系, 组装类并提供执行入口.
在面向对象这一部分, 还额外讲到了两个设计思想: 基于接口而非实现的设计思想, 多用组合少用继承的设计思想. 这两个设计思想虽然简单, 但非常实用, 应用它们能让代码更加灵活, 更加容易扩展.
# 2.设计原则
在专栏最开始总结了一套评判代码质量的标准, 比如可读性, 可维护性, 可扩展性, 复用性等, 这是从代码整体质量的角度来评判的. 但落实到具体的细节, 往往从是否符合设计原则, 来对代码设计进行评判. 比如这段代码的可扩展性比较差, 主要原因是违背了开闭原则. 这也就是说, 相对于可读性, 可维护性, 可扩展性等代码整体质量的评判标准, 设计原则更加具体, 能够更加明确地指出代码存在的问题.
在专栏中重点讲解了一些经典的设计原则. 它们分别是 SOLID 原则, DRY 原则, KISS 原则, YAGNI 原则, LOD 原则. 这些原则的定义描述都很简单, 看似都很好理解, 但也都比较抽象, 比较难落地指导具体的编程. 所以, 学习的重点是透彻理解它们的设计初衷, 掌握它们能解决哪些编程问题, 有哪些常用的应用场景.
SOLID 原则并非一个原则. 它包含: 单一职责原则(SRP), 开闭原则(OCP), 里氏替换原则(LSP), 接口隔离原则(ISP), 依赖倒置原则(DIP). 其中, 里氏替换和接口隔离这两个设计原则并不那么常用, 稍微了解就可以了. 并且重点学习了单一职责, 开闭, 依赖倒置这三个原则.
单一职责原则是类职责划分的重要参考依据, 是保证代码 "高内聚" 的有效手段, 是面向对象设计前两步(划分职责并识别出有哪些类, 定义类及其属性和方法)的主要指导原则. 单一职责原则的难点在于, 对代码职责是否足够单一的判定. 这要根据具体的场景来具体分析. 同一个类的设计, 在不同的场景下, 对职责是否单一的判定, 可能是不同的.
开闭原则是保证代码可扩展性的重要指导原则, 是对代码扩展性的具体解读. 很多设计模式诞生的初衷都是为了提高代码的扩展性, 都是以满足开闭原则为设计目的的. 实际上, 尽管开闭原则描述为对扩展开放, 对修改关闭, 但也并不是说杜绝一切代码修改, 正确的理解是以最小化修改代价来完成新功能的添加. 实际上, 在平时的开发中, 要时刻思考, 目前的设计在以后应对新功能扩展的时候, 是否能做到不需要大的代码修改(比如调整代码结构)就能完成.
依赖倒置原则主要用来指导框架层面的设计. 高层模块不依赖低层模块, 它们共同依赖同一个抽象. 深挖一下的话, 要把它跟控制反转, 依赖注入, 依赖注入框架做区分. 实际上, 比依赖倒置原则更加常用的是依赖注入. 它用来指导如何编写可测试性代码, 换句话说, 编写可测试代码的诀窍就是应用依赖注入.
KISS, YAGNI 可以说是两个万金油原则, 小到代码, 大到架构, 产品, 很多场景都能套用这两条原则. 其中, YAGNI 原则表示暂时不需要的就不要做, KISS 原则表示要做就要尽量保持简单. 跟单一职责原则类似, 掌握这两个原则的难点也是在于, 对代码是否符合 KISS, YAGNI 原则的判定. 这也需要根据具体的场景来具体分析, 在某个时间点, 某个场景下, 某段代码符合 KISS, YAGNI 原则, 换个时间点, 换个场景, 可能就不符合了.
DRY 原则主要是提醒你不要写重复的代码, 这个倒是不难掌握. LOD 原则又叫最小知道原则, 不该有直接依赖关系的类之间, 不要有依赖; 有依赖关系的类之间, 尽量只依赖必要的接口. 如果说单一职责原则是为了实现 "高内聚", 那这个原则就是为了实现 "松耦合".
# 3.编码规范
编码规范很重要, 特别是对于初入职, 开发经验不多的程序员, 遵从好的编码规范, 能让你写出来的代码至少不会太烂. 而且, 编码规范都比较具体, 不像设计原则, 模式, 思想那样, 比较抽象, 需要融入很多个人的理解和思考, 需要根据具体的场景具体分析, 所以它落地执行起来更加容易.
虽然讲了很多设计思想, 原则, 模式, 但是, 大部分代码都不需要用到这么复杂的设计, 即便用到, 可能也就只是用到极个别的知识点, 而且用的也不会很频繁. 但编码规范就不一样了. 编码规范影响到你写的每个类, 函数, 变量. 编写每行代码的时候都要思考是否符合编码规范.
除此之外, 编程规范主要解决代码的可读性问题. 个人觉得, 在编写代码的时候, 要把可读性放到首位. 只有在代码可读性比较好的情况下, 再去考虑代码的扩展性, 灵活性等. 一般来说, 一个可读性比较好的代码, 对它修改, 扩展, 重构都不是难事, 因为这些工作的前提都是先读懂代码.
# 4.重构技巧
重构作为保持代码质量不腐化的有效手段, 利用的就是面向对象, 设计原则, 设计模式, 编码规范这些理论. 在重构的过程中, 用代码质量评判标准来评判代码的整体质量, 然后对照设计原则来发现代码存在的具体问题, 最后用设计模式或者编码规范对存在的问题进行改善.
持续重构除了能保证代码质量不腐化之外, 还能有效避免过度设计. 有了持续重构意识, 就不会因为担心设计不足而过度设计. 先按照最简单的思路来设计, 然后在后续的开发过程中逐步迭代重构.
在专栏中, 还对重构进行了粗略的分类, 分为大规模高层次的重构和小规模低层次的重构. 不管哪种重构, 保证重构不出错, 除了熟悉代码之外, 还有就是完善的单元测试.
# 5.设计模式
如果说设计原则相当于编程心法, 那设计模式相当于具体的招式. 设计模式是针对软件开发中经常遇到的一些设计问题, 总结出来的一套解决方案或者设计思路. 用设计原则来评判代码设计哪里有问题, 然后再通过具体的设计模式来改善. 相对于其他部分来讲, 设计模式是最容易学习的, 但也是最容易被滥用的.
经典的设计模式有 23 种, 分三种类型: 创建型, 结构型和行为型. 其中, 创建型设计模式主要解决 "对象的创建" 问题, 结构型设计模式主要解决 "类或对象的组合" 问题, 行为型设计模式主要解决 "类或对象之间的交互" 问题.
虽然专栏中讲到的设计模式有很多种, 但常用的并不多, 主要有: 单例, 工厂, 建造者, 代理, 装饰器, 适配器, 观察者, 模板, 策略, 职责链, 迭代器这 11 种, 所以只要集中精力, 把这 11 种搞明白就可以了, 剩下的那 12 种稍微了解, 等到真正用到的时候, 再深入地去研究学习就可以了.
# 100-如何将设计思想,原则,模式等理论知识应用到项目中?
实际上, 很多小伙伴反应, 虽然理论掌握得差不多, 专栏也很贴近实战, 每个知识点的讲解都有结合实际的代码案例, 并且最后还有集中的项目实战, 但落实到自己写代码的时候, 还是无法将学到的理论知识很好地应用到其中. 今天就再聊一聊, 如何将设计思想, 原则, 模式等理论知识应用到实际的项目开发中.
吃透理论,先把书读厚再把书读薄
把理论知识灵活地应用到实践的前提是, 对理论有透彻, 无盲点的理解. 如果对理论知识掌握得似懂非懂, 在实际软件开发中, 遇到跟专栏中讲过的问题类似, 可以照葫芦画瓢去解决, 但如果问题背景稍有改变, 就会比较难联想到对应的理论知识, 更难灵活地应用理论去解决.
要想透彻理解专栏中的每个知识点, 一个是要多看几遍, 二是要有死磕精神. 虽然这两个方法可能已经是老生常谈了, 听起来也没有什么高大上的, 但从自身的学习经验来讲, 它们确实很有用.
书读百遍其义自见. 有的时候, 对某个知识点, 如果看一遍看不懂, 就硬着头皮多看几遍, 或者隔几天再回过头来看一遍, 你会发现原来很多看不懂的地方, 自然而然就懂了.
慢就是快, 快就是慢. 专栏涉及的内容很多, 但花一年把所有的知识点学透彻, 实际上是件一劳永逸的事情. 这个过程虽然看似漫长, 但收益却很多. 对比而言, 如果只是为了追求结课速度, 花一两个月, 甚至一两个礼拜, 把课程学完. 这看似很快, 但实际上收获会很少.
先把书读厚, 再把书读薄. 反复地学, 持续地看. 先把书读厚, 等到把所有的知识点都理解透彻, 并且在脑子里建立起清晰的知识体系之后, 你会发现, 实际上专栏的内容也就那么点东西, 并不难记忆. 前提是你先要花时间把书读厚, 然后才能做到把书读薄.
虽然这里讲到书读百遍其义自见, 也讲到死磕精神, 但必须强调一下, 有的时候, 对于某个知识点, 如果看了很多遍, 死磕了很长时间, 如果还是没法透彻理解, 也不要过于钻牛角尖, 非得"现在立刻马上就要"把它拿下. 可以先把这个知识点放一放, 先看看后面的内容, 隔一段时间, 让知识沉淀, 消化一下, 再回过头来看也是可以的.
在实战中反复学习,模仿和借鉴
很多人说, 理论的知识学了就忘, 忘了是不是就等于白学了呢? 实际上不是的, 起码对于这个专栏的内容来说, 并非如此. 在专栏中, 几乎每个知识点, 都结合具体的案例和代码来讲解, 目的就是为了让你在实战中学习. 所以学习的重点不是理论知识, 而是跟随我的思维逻辑, 学习如何分析代码问题, 解决代码问题. 通过专栏, 经过上百个代码案例的剖析学习, 即便理论知识你有可能会忘记, 但这种潜移默化的能力锻炼, 是不会丢掉的.
对于这个专栏来说, 如果你项目经验不多, 要想把理论一下子就灵活地应用到项目中, 实际上这个要求也有点过高了. 同样, 你也可以先从模仿开始, 对于项目中遇到的跟专栏中相似的开发场景, 可以借鉴专栏中的设计思路, 代码实现. 实际上, 除了专栏中的案例之外, 还有很多借鉴的来源, 比如前面剖析过的经典开源项目(Spring, MyBatis), 还有项目中大牛同事写的代码等等.
有人为了刷 LeetCode, 刷算法题, 会积累一些算法模板, 对于相似的问题, 套用算法模板来快速解决. 同样的, 我们也可以积累设计模板, 代码模板, 对于相似的功能需求, 可以套路设计模板, 代码模板来解决. 比如前面讲到的限流框架, 灰度组件中, 加载配置文件这样一个常用功能的设计和实现, 就可以抽象成模板. 对于其他项目中类似的功能需求, 直接套用就可以, 不用从零开始设计和实现了.
刻意思考,刻意训练,追求极致
要想把理论知识应用到项目中, 并且做到润物细无声, 融会贯通, 无招胜有招, 需要经过漫长的刻意思考和刻意训练.
拿到一个功能需求的时候, 先去思考一下如何设计, 而不是上来就写代码. 写代码时, 也要时刻思考代码是否遵循了经典的设计思想, 设计原则, 比如是否足够可扩展, 是否满足 SOLID 原则, 可读性如何等等.
写完代码之后, 再思考一下, 代码是否有进一步优化的空间. 做 Code Review 的时候, 看到别人的优秀的代码, 就去思考一下, 有哪些值得借鉴的地方.
总之, 在平时的开发中, 要刻意的去做这种跟代码质量, 代码设计相关的思考训练. 时间长了, 这种思考就能成为习惯和本能反应, 慢慢地, 代码能力也就不自觉地提高了.
刻意训练的过程在前期会比较痛苦. 为了尽可能写出高质量的代码, 为了刻意训练在代码中应用理论知识, 原本半天就能写好的代码, 可能需要好几天才能完成. 在最开始的时候, 建议把专栏中讲到的经典的设计思想, 原则, 模式, 打印出来贴在电脑旁, 每次写代码的时候, 对照着每个知识点, 一个一个去审视代码.
跟前面讲到的花很多时间把理论知识搞透彻的道理一样, 刻意训练虽然在前期需要投入更多的时间和精力, 但也是一件一劳永逸的事情. 等到训练到一定程度之后, 就会发现, 在不依赖这个知识点列表的情况下, 你开始不自主地考虑代码质量问题, 设计问题, 不经意写出的代码, 就完全符合高质量代码的要求了, 而且写出好的代码并不会花费更多的时间了. 相反, 如果不愿意为刻意训练付出时间和精力, 每次写代码都马马虎虎, 代码质量永远都提高不了, 也永远都达不到灵活应用理论知识到项目中.
多花点心思和时间把一段代码写好, 优化到极致, 比写十段凑活能用的代码, 对提高代码能力更有效. 实际上, 这就好比刷 LeetCode 算法题, 对于一些经典算法的经典题目, 一定要刻意地多花点时间搞清楚, 死磕一下. 虽然死磕的过程很痛苦, 可能会花掉你很多时间, 但一旦搞明白之后, 其他类似的题目都可以很快解决. 相反, 如果看到不会的问题, 连思考都不思考, 就去看答案, 那做十道题, 也还是没有太多长进, 看到题目不看答案还是写不出来.
# 加餐:基础学科的知识如何转化成实际的技术生产力?
我相信, 很多程序员都已经意识到基础知识的重要性, 觉得要夯实基础, 才能走得更远, 但同时对于如何将基础知识转化成开发 "生产力" 仍然有些疑惑. 所以, 你可能看了很多基础的书籍, 比如操作系统, 组成原理, 编译原理等, 但还是觉得很迷茫, 觉得在开发中用不上, 起码在平时的 CRUD 业务开发中用不上.
实际上, 这些基础的知识确实很难直接转化成开发 "生产力", 但并不代表就没有用, 今天就聊一聊为什么要学习基础学科知识, 以及基础学科知识是如何转化成 "生产力" 的?
# 1.没有直接用得上并不代表没有用
很多人反映, 大学里学的很多基础学科都没用, 工作之后都用不到. 这种感觉是没错的. 实际上, 不只是基础学科, 任何一门技术, 能够经常用在工作中的那部分, 都只占 20% 左右. 这也符合 "二八法则".
构建一个完善的知识体系, 知识框架很重要. 有些知识能直接转化成生产力, 有些知识是为了学习其他知识充当 "脚手架". 要想对应用层的知识学得比别人快, 学得比别人透彻, 铺垫性的基础学科知识就必不可少.
# 2.现在用不到并不代表以后用不到
你可能会说, 我科班出身, 基础扎实, 但跟其他培训出身, 只会使用框架干活的同事, 在工作中的表现差不多啊, 并没有感觉到有太多优势. 实际上, 如果只是做比较 "低级" 的 CRUD 工作, 项目本身没有难度, 没有挑战, 那基础再扎实, 技术再好, 可能也都发挥不了你的优势, 跟其他人拉不开差距. 这就相当于跟大妈在菜市场比算账, 你高数, 微积分学得再好都没用, 都比不上大妈算得快. 但换个有挑战的工作环境, 换个有难度的项目, 又或者当你成为更高级别的工程师的时候, 这些基础知识可能就会用得上了.
编程这件事本来就不难, 随便培训 3, 5 个月, 就能上手干活. 基础知识掌握得牢固, 在工作初期, 可能确实体现不出优势, 毕竟大家都是从最基础, 最没有技术含量的活干起的. 在初期, 那些框架, 工具用得熟练的人, 反倒更有优势, 更容易出活, 干得更快. 但只会框架, 工具的人, 技术天花板是很低的, 很多东西都只能学得一知半解, 无法深入, 很快就会遇到技术成长的瓶颈. 相反, 基础扎实的人更经得起时间的考验, 技术研究的越深入, 优势就会越明显. 尽管基础知识短期内没法给你带来收益, 但长远来看会持续发挥作用.
很多知识用不到, 可能只是暂时用不到. 书到用时方恨少. 等到用到了, 再去学习, 就有点来不及了. 基础学科知识学起来比较慢, 所以还是要在平时多下功夫, 提前学扎实. 学习基础学科的知识, 不能太急功近利, 只看重眼前的利益.
# 3.学了记不住并不代表就是白学了
对于知识的学习, 特别是偏理论的基础学科知识的学习, 有很多人说, 学完之后, 关上书啥都回忆不起来, 学完不用, 过不几天就忘得干干净净. 其实, 这些现象是很正常的. 实际上, 如果你一直追求 "记住", 那就还是应试教育的思维方式.
知识是用来解决问题的, 而不是用来记忆考试的. 说句实话, 你可能不信, 我在写这两个专栏的时候, 写完后面的就忘了前面的, 全部写完之后, 基本就都忘得差不多了. 不过, 因为我在脑海里构建了整个的知识框架, 并且大部分知识难点都已经被我攻克, 所以当工作中需要某块知识的时候, 即便细节记不清了, 我也只需要稍微查看一下资料, 就能全部回忆起来.
总结一下, 比起编程语言, 框架, 工具, 基础学科知识确实很难直接转化成生产力, 但它却是你构建整个 "技能树" 的根本, 构建整个 "知识大楼" 的地基. 基础掌握不牢, 你对很多应用层技术的理解就会不够有深度, 略知皮毛, 只能做个技术熟练工. 相反, 基础扎实能让你学东西更快, 更有深度, 理解更透彻, 也就间接地增强了你的开发能力. 可以这么说, 在一定程度上, 基础知识本身, 就是技术生产力.
# 加餐:作为面试官或候选人,如何面试或回答设计模式问题?
很多人反映, 在面试中被问到设计模式问题的时候, 一般都没有什么思路, 基本都是想到哪说到哪. 今天就总结一下回答设计模式相关面试题的一些套路, 希望能让你在今后的面试中有章可循.
# 1.作为面试官,如何面试设计模式问题?
有些面试官喜欢让候选人手写常用的设计模式, 比如单例模式, 工厂模式, 以此来考察候选人对设计模式的掌握程度. 实际上, 对于比较常用的设计模式, 盲写的要求并不过分, 毕竟在开发中, 徒手写个单例模式, 工厂模式, 也是常有的事情.
不过, 这种偏向记忆的面试题目, 实际上是一种应试考试的面试方式. 一方面, 它没有区分度, 另一方面, 候选人容易突击准备. 这往往考察不出候选人真正的代码设计和实现能力. 学习设计模式的初衷是提高代码质量. 学习设计模式的重点, 是掌握应用场景, 能解决哪些问题, 而非记忆定义, 代码实现. 所以, 我面试时有个原则, 不直接问记忆性问题和过于理论性问题.
筛选候选人就是筛选将来与你共事的人. 面试的最终目的, 还是希望能在短短的 1 小时内, 粗略地看出候选人在今后工作中的表现. 相对应的, 在面试中考察候选人设计模式相关的知识, 是看他在今后的项目中, 能否写出易读, 易扩展, 易维护的高质量代码.
为了更准确地反映候选人在以后的工作中的表现, 最好的面试方式是拿真实项目来考察, 而且最好是候选人入职之后要参加的项目. 当然, 这个要求稍微有点高了. 一般来讲, 其实只要比较贴近真实项目就可以了.
**对设计和代码能力的考察, 我一般有两种面试思路. **
第一种, 给候选人一个功能需求, 让他去做代码设计和实现, 然后基于他的代码实现, 讨论代码的可读性, 扩展性等代码质量方面的问题, 然后让候选人继续优化, 直到满意为止. 第二种是, 给候选人一段有质量问题的代码, 让他去做 Code Review, 找出代码存在的问题, 然后做代码重构. 专栏中很多文章中的例子, 都符合刚刚两种面试思路.
重点强调一下, 这种代码设计实现问题, 本身没有标准答案, 背景又过于复杂开放, 如果只是丢给候选人回答, 中间没有任何交流和引导, 候选人很难抓住重点, 展现出你心里期望的表现. 所以面试的过程切忌像笔试一样, 一问一答单向沟通. 相反要把面试当做一场与未来同事的技术讨论, 在讨论的过程中去感受候选人的技术实力.
当候选人写完代码之后, 如果面试官一个问题都不提, 然后就跳到其他面试题目, 这种体验, 不管是对候选人, 还是面试官来说, 都不是很好. 相反, 如果面试官能一语中的地提出设计中的缺陷, 深入地跟候选人去讨论, 这样一方面能给候选人充分发挥的机会, 另一方面, 也会赢来候选人对公司技术的认可.
# 2.作为候选人,如何回答设计模式问题?
刚刚从面试官的角度, 讲解了如何面试设计模式相关的问题. 现在再从候选人的角度, 讲下如何回答设计模式相关的问题.
刚刚讲到, 很多面试官喜欢让候选人手写常用设计模式的代码实现, 虽然我本身比较讨厌这种面试方式, 但保不齐有些面试官喜欢. 应对这种面试问题, 就只能面试前突击复习一下了.
我个人比较喜欢拿真实的功能需求和代码来面试候选人. 一种面试题是给功能需求, 让候选人写代码, 另一种面试题是给代码, 让候选人做 Code Review 和代码重构. 针对这两种类型的面试题, 下面分别讲讲应该如何应对.
对于第一种面试题目, 首先要明确需求. 大部分情况下, 面试官给出的功能需求, 都是比较笼统, 模糊的, 这本身就是为了考察你的沟通能力, 分析能力, 是否能通过挖掘, 假设, 取舍, 搞清楚具体的需求, 梳理出可以执行落地的需求列表.
跟面试官确定好需求之后, 就可以开始设计和实现了. 前面也提到, 面试的目的是考察候选人在真实项目开发中的表现. 在工作中, 都是从最简单的设计和实现方案做起, 所以回答这种设计面试题, 也不要一下子就搞得太复杂, 为了设计而设计, 非得用些比较复杂的设计模式.
不过, 在用最简单方式实现之后, 可以再讲一下, 如何基于某某设计模式, 进一步对代码进行解耦, 进一步提高代码的扩展性. 基于这种最简单的代码, 再行讨论优化, 这样跟面试官讨论起来, 也会更加言之有物. 这也能体现你的代码演进思维, 以及具体问题具体分析的能力.
比起第一种题目, 第二种面试题目会更加明确, 具体. 就把它当作一次真实的 Code Review 来回答就好了.
实际上, 回答这种没有固定答案的开放性问题, 要跟面试官多问多沟通, 不要觉得问多了就是自己理解能力不够, 就会导致面试官反感. 相反, 面试官不仅不会反感, 反倒会觉得你是一个思路开阔, 有想法的人. 如果只是自己闷头写代码, 面试官有可能会觉得你不善沟通.
# 加餐:程序员怎么才能让自己走得更高, 更远?
大学就像一个笼子, 跑得快的人拖着笼子跑, 跑得慢的人被笼子拖着跑, 他们之间最大的差距, 顶多只有笼子的长度那么大. 但等到一毕业, 笼门一打开, 跑得快的人很快就把跑得慢的远远甩在后面. 有人不到 30 岁就升到了阿里 P8, 有人 35 岁了还为拿个 P7 Offer 在拼命.
为什么几乎同样的起点, 差不多的资质, 有些人在职场发展得这么好, 一路顺风顺水, 而有些人却总是觉得怀才不遇, 领导不行? 今天就来聊一聊, 程序员怎么才能让自己走得更高, 更远?
# 1.技术,业务,能力是立命之本
我觉得, 不管哪个行业, 混得好的人都要有两把刷子. 对于程序员来说, 我觉得这两把刷子包括技术, 业务和能力这三方面. 很多人担心出现 35 岁中年危机, 觉得自己能干的事, 刚毕业一两年的人也能干得了. 我觉得这主要还是因为没有在这三个方面积累出竞争壁垒.
我觉得, 技术方面的竞争壁垒主要来自, 在一个细分技术领域长期, 深入的积累 .
如果要想在技术上形成壁垒, 就要从事一些有技术难度, 技术挑战的岗位, 比如基础架构, 中间件, 数据库等偏底层的开发, 又或者是人工智能算法等入行门槛比较高的细分领域. 在这些领域需要较长时间的经验积累, 才能成为这一领域的专家, 别人无法在短期内超过你, 这就是技术竞争壁垒.
对于大部分业务开发工程师来说, 很多人平时都是使用框架, 工具, 翻译业务代码, 工作没有太多技术含量, 技术上可能很难形成竞争壁垒. 这个时候, 你也不用苦恼, 在业务上也同样可以形成竞争壁垒.
实际上, 技术驱动的公司很少, 即便像 Google 这样公认的技术驱动的公司, 里面 90% 的项目都是业务, 产品驱动的. 真正高精尖的技术也只集中在某一小撮项目中. 你可能会说, Google 的很多产品的用户, 访问量都很高, 这些高性能, 高并发的要求不是很考验程序员的技术吗? 实际上, 这些有挑战的技术问题, 都是比较有共性的, 大部分都通过底层系统解决了, 比如 MapReduce, BigTable, GFS 等. 业务研发工程师只需要恰当地使用这些高精尖的系统来实现业务就可以了.
在业务驱动的项目中, 特别是一些业务比较复杂的下项目, 比如, 金融系统, 银行系统, 财务系统, 清结算系统, 物流系统等, 还可以积累一些业务壁垒. 如果你之前的工作都是从事偏向业务系统的开发, 靠技术只能面到阿里 P7 这个层级. 如果想面到阿里的 P8, P9, 靠的就不仅仅只是技术了, 还需要对某个业务的深入积累. 实际上, 很多领导之所以能做领导, 不是技术牛逼, 而是对业务熟悉.
不过, 并不是所有的业务系统开发, 业务都有足够的复杂度, 可以让你积累竞争壁垒. 对于技术没有太大挑战, 业务也不复杂的项目开发, 可以多积累自己的能力.
这里所说的能力, 指的是成事能力, 解决问题的能力. 实际上, 很多问题的解决, 即便是技术问题的解决, 靠的都不是技术, 而是一个人最基本的解决问题的能力. 这其中就包括分析总结能力, 逻辑思维能力, 沟通协调能力, 自我驱动能力等等.
比起固定的技术和业务知识, 这种成事能力, 解决问题的能力, 我觉得对于混职场来说可能更加重要. 在职场中, 职位越高, 这种成事能力就越重要, 毕竟企业最终还是看结果的, 而不是看你技术有多好.
# 2.不要让职场软技能成为短板
在职场中, 我常常听到有人抱怨说, 旁边的同事明明技术一般, 却升到很高的职位, 而自己技术很好, 却发展一般. 实际上, 怀才不遇大多数都是因为忽视了职场软技能. 这些人的特点大多是性格耿直, 脾气暴躁, 眼里容不了沙子, 斗天斗地斗空气. 而且, 大部分情况下, 他们都会觉得自己做得很好, 领导不识货, 同事都没他强.
职场不是学校, 影响你向上发展的因素很多, 肯定不是单靠技术, 所以, 学生思维要不得. 在上学的时候, 学得好坏, 一份试卷见分晓. 要想成绩好, 闷头学就行了, 你也不需要什么团队合作. 但是, 毕业之后, 技术的好坏, 代码写得好坏, 活干得好坏, 就没有那么容易客观评价, 量化评价了. 所以这就会出现你自己觉得工作做得很好, 而领导却不这么认为的情况.
而且, 技术好其实并不代表贡献多. 我也见过很多技术好的人, 比较爱自嗨, 成天鼓捣些高精尖的技术. 实际上, 不管是从短期还是长期看, 这些技术都没有给团队, 公司带来收益. 当然, 我也并不是完全摒弃个人成长, 让你完全奉献给公司. 我只是觉得, 作为员工, 要学会跟公司共同成长. 只有你的成长为公司的成长贡献了力量, 为公司, 为领导解决了问题, 公司才愿意为你的付出买单, 你才有升职加薪的机会.
总的来讲, 要想职场混得好, 一些必须的沟通, 协作, 总结汇报等软技能还是不能忽视的, 当然, 我也不是推崇, 纯靠 "耍手段" 上位. 我只是觉得, 这方面起码不能成为你的短板, 不要让这些非技术, 非能力的因素, 阻碍了你职场的发展.
# 加餐:如何接手一坨烂业务代码?如何在烂业务代码中成长?
在我们的职业生涯中, 很少有机会可以从零开发一个项目, 大部分都是接手别人的代码继续开发, 或者做些维护性开发. 而且, 对于大部分业务系统来说, 因为业务导向, 需求倒逼, 开发工期紧, 团队往往都不是很重视代码质量, 快速上线是第一要务. 所以, 很多团队的代码质量一般都不怎么高. 埋坑无数, 没有文档, 也没有注释, 代码读不懂, 也不敢改, 这对于新人来说, 会非常苦恼. 今天就聊一聊, 如何接手一坨烂业务代码, 以及如在烂业务代码中的成长?
# 1.如何接手一坨烂业务代码?
在过去 10 年的工作经历中, 我接手过很多个代码质量比较烂的项目. 这些项目都有很多共性的特点, 大部分都已经维护了两三年, 甚至五六年之久, 代码量很大, 有十几万行以上, 并且大部分代码都没有任何注释, 业务功能非常庞杂, 也没有对应的业务文档.
除此之外, 代码中还充斥着各种临时解决方案(Workaround), 硬编码(Hard Code), 遗留代码(Legacy Code), 还有很多匪夷所思的设计. 对于有些设计来说, 我们称之为 "反人类" 设计或者 "故意挖坑", 一点都不为过.
实际上, 要想接手一个业务系统, 前提是要读懂代码, 而读懂代码的关键, 是要熟悉业务. 只要业务搞清楚了, 代码只不过是对业务的翻译, 对照着业务看代码实现, 看懂并不是件难事. 不过, 我所接手的这几个项目, 基本上都是零文档, 所有的业务知识都是靠口口相传. 所以搞清楚业务, 就成了接手项目最难的事情了.
面对如此庞大的项目代码, 没有文档, 几乎就是两眼一抹黑. 原来参与这个项目开发的老同事, 有的离职, 有的去做其他新项目, 一直问他们也不好意思, 所以大部分情况下, 都只能硬着头皮, 通过阅读代码反推业务功能.
如果代码质量比较高, 模块划分清晰, 命名规范, 那通过读代码反推业务, 也并非不可能的事情. 但真实的情况往往事与愿违, 就像前面提到的, 代码中充斥着临时解决方案, 硬编码, 遗留代码等各种坑, 这就使反推业务变得非常困难. 对于代码中的这些坑, 尽管我不想一直麻烦同事, 但也只有多问才能最快速地解决.
在读代码的过程中, 我非常重视知识的文档化, 我会把读懂的每个业务都写到文档中. 当然这其中也包括前面提到的各种坑. 对于复杂的业务流程, 我还会画一些流程图. 读代码的过程非常痛苦, 花了好几个月, 我才有信心说, 自己几乎把所有代码都搞清楚了. 同时我也做了一件过去几年都没有人做的事情, 那就是补充完整了技术文档和业务文档, 之后再有新同事加入, 看了我的文档, 就可以很快了解代码, 了解业务, 很快就能上手开发代码.
总结一下, 即便代码再烂, 只要有完善的业务文档, 先理解业务, 再去看代码, 几乎就没啥难度了. 对于零文档的项目, 大部分情况下, 只能通过代码来反推业务. 当然, 对于有些坑来说, 必要的情况下, 也要询问前辈来搞定. 在读代码的过程中, 要将得到的知识文档化, 这也是对公司和团队来说最有价值的部分.
# 2.如何在烂业务代码中成长?
有人一遇到这种烂业务代码, 就觉得很心烦, 我反倒不一样. 恰恰相反, 相比接手好代码, 我觉得接手烂代码, 虽然过程更加痛苦, 但同时也会给我更多施展才华的空间, 锻炼技术的机会, 我的成长也会更多.
除此之外, 很多人觉得做偏底层的开发(基础架构, 框架, 中间件等开发)才锻炼技术, 做业务系统没有挑战, 技术上没有成长, 对此非常苦恼. 实际上, 我觉得这种看法是比较片面的. 做业务开发的难度不亚于底层开发, 做好也不是件容易的事情, 同样可以积累技术, 锻炼能力.
偏底层的开发更加考验程序员在某一细分领域的技术深度, 偏业务的开发更加考验程序员的能力, 比如沟通能力, 分析问题解决问题能力, 权衡取舍能力, 架构能力等, 毕竟业务多种多样, 问题千奇百怪, 单一细分领域的经验很难应对所有问题.
**实际上, 业务系统的开发难度一般来自两个方面: 高性能要求和业务复杂. **
解决性能问题, 需要具备一定的架构能力, 有一定的技术广度, 需要对各种基础架构, 框架, 中间件都有所了解. 光了解还不够, 还要有一定的技术深度, 最好能对原理甚至是源码有所研究. 除此之外, 还要有一定的使用经验. 广度, 深度, 经验三者配合, 这样才能做到恰到好处组合这些技术搭建架构, 解决性能问题, 并且在出现问题之后才能快速地解决.
应对大型项目的业务复杂性, 要想让项目代码一直在你的掌控范围内, 需要有很强的业务建模能力, 复杂逻辑的抽象能力, 代码翻译能力等. 对于一个人的基本素质, 基础能力的要求要更高. 实际上, 对于复杂业务系统来说, 对业务的熟悉也能成为你的竞争壁垒, 成为升职加薪的砝码.
如果你参与的项目, 性能要求高, 业务也复杂, 那恭喜你, 好好干就成了. 如果你参与的项目, 在性能和复杂度上, 只兼具其中一点, 那也不错, 值得一做. 如果你参与的项目, 既没有性能压力, 业务也不复杂, 那也别太着急, 走着瞧, 实在不行再跳槽.
# 结束语-聊一聊机遇,方向,能力,努力!
在这篇结束语里面, 我打算结合自己写专栏的经历, 聊一聊对人一生有很大影响的四样东西: 机遇, 方向, 能力, 努力, 我觉得它们一起决定了你是否能 "成事".
# 1.机遇
我觉得, 能赶上 "知识付费" 的风口, 在最火热的时间段, 推出了算法专栏, 这是算法专栏能卖得这么好的最主要的外在原因. 当时, 大部分人对 "知识付费" 还认识不清, 看到有人来讲课, 分享经验, 激动不已, 于是开始一直 "剁手买买买". 除此之外, 还有一个很大原因是, 当时并没有多少人讲算法, 碰巧很多人在算法知识这方面也比较欠缺, 所以, 我的专栏不仅碰到了风口, 还抢占了先机. 现在, 风口已经过去, 大部分人对 "知识付费" 的认识也变得越来越理性. 所以, 如果算法专栏现在才推出, 没有风口, 没有先机, 我不敢说一定卖得不好, 但肯定不会像之前那么火爆.
人生很长, 每个人都有翻盘的机会, 就看你是否能抓得住了. 哈佛大学曾做过一个调查. 调查说, 人的一生一般会有七次机会. 只需要抓住其中的一两次机会, 一下子就能拉开跟同龄人之间的距离, 甚至改变人生方向, 改变命运. 像我, 抓住了"知识付费"的机会, 所以就小火了一把.
# 2.方向
俗话说, 选择努力的方向, 有时候比单纯的努力更重要. 为什么没你牛的人混得比你好? 如果你找不到自身的原因, 那就只能从机遇和方向上找答案了.
之所以专栏卖得火爆, 除了遇到了"知识付费"风口之外, 还有一个很重要的外在因素, 那就是专栏的选题很好. 不管是算法, 还是设计模式, 两个专栏的选题都是比较偏底层技能, 通用知识的, 受众比较广. 相反, 如果专栏选题本身比较小众, 写得再好, 可能买的人也不会很多.
不过, 人生不像写专栏那么简单. 很多时候, 面对选择, 很难一眼就能断定哪个会更好, 往往都是事后分析, 觉得某某选择很正确或者很后悔, 甚至大部分情况下, 都只能被动选择. 就拿我自己来举例吧, 有些朋友知道, 在写设计模式这个专栏的时候, 我离职了, 说实话, 是被迫离职.
因为写专栏确实比较费时间, 费精力, 我很难平衡工作, 生活, 写专栏三件事. 生活不能耽误, 专栏也不能停更, 所以, 我只能被迫选择放弃工作了. 这样的选择, 对我来说还是挺难的. 因为一旦选择离开职场, 就意味着要选择一种新的 "讨生活" 方式. 而新的 "讨生活" 方式, 到底是不是更好的方式, 还要过几年才能见分晓.
# 3.能力
刚刚聊的是两个外因, 现在再聊下内因. 实际上, 很少有风口, 机会, 方向是只有你自己能看到, 而别人看不到的. 你能看到的, 很多人都能看到. 但并不是任何扑风口的人, 都能飞上天. 要想成功, 你的能力也非常重要.
就像我的算法, 设计模式专栏一样, 这两个选题方向, 估计很多人都想得到. 而且, 在我写之前, 很多知识付费平台也已经上线了类似选题的课程. 不过, 这些课程大都不温不火. 我的算法能带动算法学潮, 设计模式能逆势而上, 也说明了我多少有两把刷子. 即便相同的选题方向, 换做其他人来写, 未必有我写得好, 未必有我的课卖得好.
前面我也提到, 因为写设计模式专栏, 我选择了从职场离开. 实际上, 这并非拍脑袋决定的. 我自认为在"卖弄文字""教书育人"方面还是有点优势的. 当然, 这样的认知并非自我感觉良好, 有客观数据和事实来支撑. 所以, 选择离开职场, 从零开始一个新的方向, 集中精力 All In 到自己擅长的事情上, 对我来说, 可能是"被迫"做的更好的选择.
# 4.努力
"努力决定下限, 运气决定上限", 这句话我想你应该听过吧? "运气" 可遇而不可求, 但努不努力是自己可以掌控的. 在任何领域, 想要做到 TOP 1, 可能需要一些运气和天赋, 但要做到 TOP 10, 一般人只要努力就能做到. 这就好比创业, 要做成今日头条, 拼多多那样金字塔尖上的企业很难, 可能不是简单努力就能成的, 但要只是想讨个生活, 很多时候只要努把力就够了.
# 5.最后
时光不语, 静等花开. 从默默无闻到崭露头角, 一般只需一瞬间, 看似一瞬间, 可能要等十几年. 年轻人一定不要心急, 不要焦虑, 要耐得住性子. 当你的能力撑不起你的野心的时候, 当你感到怀才不遇的时候, 当你迷茫找不着方向的时候, 你只需要努力, 坚持, 再努力, 再坚持, 慢慢地, 你就会变得越来越强大, 方向就会变得越来越清晰, 机会就会越来越青睐你.